Tracking now comes from androidx's TypeScaleTokens.kt as sp/16 rem, so Display Large is -0.0125rem, Title Medium and Body Medium 0.0125rem where material-web's pre-Expressive numbers stood. The emphasized set gets its own --md-sys-typescale-emphasized-<style>-tracking, which the emphasized utilities read: M3 widens four of them and takes Display Large back to zero. Every regular utility sets `font-variation-settings: normal`, because the property inherits and body copy inside an emphasized heading is meant to read as itself. bin/check-font.mjs (fontkit) prints the packaged woff2's fvar axes, so a re-subset cannot silently drop the ROND axis the emphasized styles depend on; `npm run check:font` runs it and tests/Feature/FontTest.php runs it through the configured node binary, skipping when node cannot run (PHP cannot open a woff2 without Brotli). Plan: docs/plans/material-3-alignment.md step 4, findings core C5, C6, C7. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
857 lines
63 KiB
Markdown
857 lines
63 KiB
Markdown
---
|
||
name: livewire-material-development
|
||
description: Build Laravel and Livewire views with Livewire Material's Material 3 Expressive Blade components — props, slots, colour roles, type, shape, motion, theming, toasts, the design guard, and the Livewire traps each component handles.
|
||
---
|
||
|
||
# Livewire Material Development
|
||
|
||
## When to use this skill
|
||
|
||
Use this skill when writing or changing any Blade view, Livewire component view or layout in an application that requires `nonameweb/livewire-material`, and when styling, theming or testing such views.
|
||
|
||
## Setup
|
||
|
||
Composer packages must be installed before the Vite build (in Dockerfiles and CI alike), because the application's build imports from `vendor/`:
|
||
|
||
```css
|
||
/* resources/css/app.css */
|
||
@import 'tailwindcss';
|
||
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
|
||
@import './material-scheme.css';
|
||
@source '../../vendor/nonameweb/livewire-material/resources/views';
|
||
@source '../../vendor/nonameweb/livewire-material/src';
|
||
```
|
||
|
||
```js
|
||
// resources/js/app.js
|
||
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||
```
|
||
|
||
Every layout puts the theme script in `<head>`, before `@vite`:
|
||
|
||
```blade
|
||
<head>
|
||
<x-theme-script />
|
||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||
</head>
|
||
```
|
||
|
||
## Colour scheme
|
||
|
||
The scheme is generated, never hand-edited. Regenerate it with the seed and variant recorded at the top of `resources/css/material-scheme.css`:
|
||
|
||
```bash
|
||
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||
```
|
||
|
||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet.
|
||
|
||
### Colour profiles
|
||
|
||
An installation that switches between several schemes lists them in `config/livewire-material.php` and runs the command without a seed, which generates every profile into the same stylesheet, keyed by `<html data-scheme>`:
|
||
|
||
```php
|
||
'profiles' => [
|
||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||
],
|
||
'profile' => 'indigo', // the default; else the first
|
||
```
|
||
|
||
```bash
|
||
php artisan material:scheme
|
||
```
|
||
|
||
- Each profile: `seed`, and optionally `label` (default: the name as a headline), `variant` (default `tonal-spot`), `contrast` (default 0), `spec`, `success`, `warning`, `info` (for these four, without the key the command's `--spec`, `--success`, `--warning`, `--info` or their defaults apply).
|
||
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
|
||
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
|
||
|
||
```php
|
||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||
|
||
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||
```
|
||
|
||
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
|
||
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
|
||
|
||
## Tokens
|
||
|
||
Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing.
|
||
|
||
- Colour roles (`bg-*`, `text-*`, `border-*`, …): `primary`, `on-primary`, `primary-container`, `on-primary-container`, `inverse-primary`, `primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`, `on-primary-fixed-variant`; the same for `secondary` and `tertiary`; `error`, `on-error`, `error-container`, `on-error-container`; `success`, `warning` and `info` with their `on-`, `-container` and `on-…-container`; `inverse-error|success|warning|info`; `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest|low||high|highest`, `on-surface`, `on-surface-variant`, `inverse-surface`, `inverse-on-surface`, `outline`, `outline-variant`, `scrim`, `shadow`; plus `white` and `black`.
|
||
- Ink and lines by meaning: `text-body` (body copy), `text-meta` (metadata), `text-quiet` (decoration only), `border-structure`, `border-chrome`, `border-divider` / `divide-divider`.
|
||
- Type: `type-{display|headline|title|body|label}-{lg|md|sm}` and `type-emphasized-…` — M3's 15 styles and their emphasized twins, one weight step heavier, rounder, with their own tracking. Never assemble `text-*`, `leading-*` and `tracking-*` by hand; a utility carries size, line height and tracking together. Emphasis is deliberate and one element at a time (M3 asks for it on badges, primary buttons, selected rows, headlines), so a regular utility inside an emphasized one goes back to plain. The font is Google Sans Flex (`font-sans`).
|
||
- Shape: `rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}`.
|
||
- Elevation: `shadow-elevation-{1…5}` — for what floats over content, not for panels (a panel separates by its container tone).
|
||
- Motion: `ease-spatial-{fast|default|slow}` (position, size, shape; springs that overshoot) and `ease-effects-{fast|default|slow}` (colour, opacity). Always pair an easing with its duration: `duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast`. Reduced motion zeroes the durations.
|
||
- States: `state-layer` (M3's hover/focus/press overlay; makes the element `relative` and `isolate`), `focus-ring` (keyboard focus indicator), `link` (a link in running text).
|
||
- `dark:` follows the page's theme (`data-theme`), not the operating system.
|
||
- `x-figure` on an element holding one number counts it up on first appearance and on change.
|
||
|
||
## Theme
|
||
|
||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`, `theme.meta`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
|
||
|
||
`theme.meta` (default `false`) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the `content` of every `<meta name="theme-color">` without a `media` attribute to the resolved theme's `surface` — of the profile in `<html data-scheme>` — before the first paint, adding one to `<head>` when there is none. It follows every later change of `data-theme` or `data-scheme` (`$store.theme.set()`/`toggle()`, an OS change while `system`, `previewScheme()`), and paints the next page's meta after `wire:navigate`. A theme-color meta the layout renders itself goes before `<x-theme-script />` (after it, the script has already added one, and the page ends up with two), or is left out. A `media="(prefers-color-scheme: …)"` pair follows the OS instead of the visitor's choice: drop it when turning this on.
|
||
|
||
## Safe areas
|
||
|
||
Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `<html>` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height.
|
||
|
||
`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, below `sm` only.
|
||
|
||
## Toasts
|
||
|
||
```php
|
||
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||
|
||
class Settings extends Component
|
||
{
|
||
use Toasts;
|
||
|
||
public function save(): void
|
||
{
|
||
// …
|
||
$this->success('Settings saved'); // also warning(), error(), info()
|
||
$this->info('Link copied', timeout: 6000);
|
||
$this->success('Share created', redirectTo: route('shares.show', $share));
|
||
}
|
||
}
|
||
```
|
||
|
||
The methods are protected. They dispatch a `toast` browser event (`assertDispatched('toast', type: 'success', title: 'Settings saved')` in tests).
|
||
|
||
## Error pages
|
||
|
||
Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to `view.paths` after the application's, so a file in `resources/views/errors/` always wins.
|
||
|
||
- The pages load `config('livewire-material.showcase.vite')` and `<x-theme-script />`, so they use the app's scheme, font and theme. While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`.
|
||
- `abort(403, 'Only the owner can open this share.')` and `abort(503, '…')` show the message as the sentence. Every other string goes through `__()`; translate them in `lang/{locale}.json`.
|
||
- To change wording or design, run `php artisan vendor:publish --tag=livewire-material-errors`, which copies the layout and pages to `resources/views/errors`. A page extends `errors::minimal` and sets `title`, `code`, `headline`, `message`, `shape` (an `<x-shape>` name) and optionally `actions`:
|
||
|
||
```blade
|
||
@extends('errors::minimal')
|
||
|
||
@section('title', __('Payment Required'))
|
||
@section('code', '402')
|
||
@section('shape', 'cookie-4')
|
||
@section('headline', __('Your plan has ended'))
|
||
@section('message', __('Choose a plan to keep using the app.'))
|
||
|
||
@section('actions')
|
||
<x-button :link="route('billing')" :label="__('Choose a plan')" variant="filled" size="md" no-wire-navigate />
|
||
@endsection
|
||
```
|
||
|
||
- Maintenance mode: `php artisan down --render="errors::503"`.
|
||
- The showcase previews each page at `/material/errors/{code}`.
|
||
|
||
## Mail
|
||
|
||
Markdown mail (notifications and `markdown:` mailables) wears M3 once the application selects the theme:
|
||
|
||
```dotenv
|
||
MAIL_MARKDOWN_THEME=livewire-material::mail.theme
|
||
```
|
||
|
||
or per mail: `(new MailMessage)->theme('livewire-material::mail.theme')`, or `public $theme = 'livewire-material::mail.theme';` on a mailable.
|
||
|
||
- Colours are the light scheme from `resources/css/material-scheme.json` (`livewire-material.scheme`), inlined as hexes; regenerate the scheme and mail follows. Without the file, the package's default scheme applies.
|
||
- Write the body as Markdown; the theme styles the bare tags (`#` headings, prose, lists, tables) with M3's typescale. `<x-mail::button :url="…">` is a filled pill in `primary`; `color` also takes `secondary`, `tertiary`, `error`, `success`, `warning` and `info`. `<x-mail::panel>` is a tinted container.
|
||
- There is no dark mail. Never put `@media` rules, CSS variables or `color-mix()` in mail CSS: the inliner strips media queries and mail clients resolve no variables.
|
||
- The package's mail header (the app name, or a logo) and message (with a replaceable footer) are opt-in: set `MATERIAL_MAIL_COMPONENTS=true`, or `php artisan vendor:publish --tag=livewire-material-mail` to copy them into `resources/views/vendor/mail`. For a logo set `livewire-material.mail.logo` to `['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]`: an absolute URL, with the image at twice those dimensions.
|
||
- With those components, a mail can replace the footer:
|
||
|
||
```blade
|
||
<x-mail::message>
|
||
Your export is ready.
|
||
|
||
<x-slot:footer>
|
||
© {{ date('Y') }} {{ config('app.name') }} · [Unsubscribe]({{ $unsubscribeUrl }})
|
||
</x-slot:footer>
|
||
</x-mail::message>
|
||
```
|
||
|
||
- The showcase renders a sample mail at `/material/mail`.
|
||
|
||
## Components
|
||
|
||
### `<x-icon>`
|
||
|
||
A Material Symbol (Rounded, weight 400, grade 0, 24px), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws.
|
||
|
||
| Prop | Default | |
|
||
|---|---|---|
|
||
| `name` | required | `calendar_month`, `cloud_upload`, `content_copy` |
|
||
| `filled` | `false` | the filled symbol — M3 uses it for active or selected |
|
||
| `label` | `null` | names the icon for screen readers when it carries the meaning alone; otherwise it is `aria-hidden` |
|
||
|
||
24px (`size-6`) unless a `size-*`, `w-*` or `h-*` class is passed. Colour follows the text: `<x-icon name="lock" class="size-5 text-on-surface-variant" />`.
|
||
|
||
### `<x-shape>`
|
||
|
||
One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, sized by its caller: `<x-shape name="cookie-9" class="size-40 text-secondary-container" />`. Names: `circle`, `square`, `slanted`, `arch`, `fan`, `arrow`, `semi-circle`, `oval`, `pill`, `triangle`, `diamond`, `clam-shell`, `pentagon`, `gem`, `very-sunny`, `sunny`, `cookie-4`, `cookie-6`, `cookie-7`, `cookie-9`, `cookie-12`, `ghostish`, `clover-4`, `clover-8`, `burst`, `soft-burst`, `boom`, `soft-boom`, `flower`, `puffy`, `puffy-diamond`, `pixel-circle`, `pixel-triangle`, `bun`, `heart`.
|
||
|
||
### `<x-theme-script>`
|
||
|
||
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`. With `theme.meta` on it also paints `<meta name="theme-color">` (see Theme); a layout's own theme-color meta goes before it.
|
||
|
||
### `<x-button>`
|
||
|
||
Label button, icon button, toggle and responsive FAB in one component.
|
||
|
||
| Prop | Default | |
|
||
|---|---|---|
|
||
| `label` / slot | | the words; without them and with an `icon` it is an icon button |
|
||
| `variant` | `text` | `filled`, `tonal`, `outlined`, `elevated`, `text` |
|
||
| `color` (alias `tone`) | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` |
|
||
| `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning |
|
||
| `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px |
|
||
| `shape` | `round` | or `square`; both square off further while pressed |
|
||
| `icon`, `icon-right` | | Material Symbol names |
|
||
| `width` | `default` | icon buttons only: `narrow`, `default`, `wide` |
|
||
| `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) |
|
||
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
|
||
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action |
|
||
| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name |
|
||
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `lg`; `fab` is an extended FAB below `sm`, a filled button above |
|
||
|
||
```blade
|
||
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
|
||
<x-button icon="delete" tooltip="Delete share" wire:click="delete({{ $share->id }})" />
|
||
<x-button icon="favorite" aria-label="Keep" variant="tonal" :selected="$kept" wire:click="toggleKeep" />
|
||
```
|
||
|
||
### `<x-tooltip>`
|
||
|
||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead.
|
||
|
||
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
|
||
|
||
```blade
|
||
<x-menu label="Share actions" position="bottom-end">
|
||
<x-slot:trigger>
|
||
<x-button icon="more_vert" tooltip="More" />
|
||
</x-slot:trigger>
|
||
|
||
<x-menu-group label="Sort by">
|
||
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="$set('sort', 'newest')" keep-open />
|
||
</x-menu-group>
|
||
<x-menu-separator />
|
||
<x-menu-item label="Settings" icon="settings" link="{{ route('settings') }}" />
|
||
<x-menu-item label="Delete" icon="delete" wire:click="delete" description="Recipients lose access" shortcut="⌘⌫" />
|
||
</x-menu>
|
||
```
|
||
|
||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||
|
||
### `<x-button-group>`
|
||
|
||
A row of `<x-button>`s: `<x-button-group label="View" size="md">…</x-button-group>`. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside.
|
||
|
||
### `<x-group>`
|
||
|
||
A choice between a few options as a connected button group of native radios (checkboxes with `multiple`):
|
||
|
||
```blade
|
||
<x-group label="Expires after" wire:model.live="expiry" :options="[
|
||
['id' => '1h', 'name' => '1 hour'],
|
||
['id' => '1d', 'name' => '1 day', 'icon' => 'today'],
|
||
['id' => '7d', 'name' => '7 days', 'disabled' => true],
|
||
]" hint="Recipients lose access after that" />
|
||
```
|
||
|
||
Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
||
|
||
### `<x-split-button>`
|
||
|
||
```blade
|
||
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
|
||
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
|
||
</x-split-button>
|
||
```
|
||
|
||
Attributes go to the leading button; the slot is the menu. `variant` (`filled` default, `tonal`, `outlined`, `elevated`), `color`, `size`, `disabled`, `spinner`, `menu-label`, `position`.
|
||
|
||
### `<x-fab>`
|
||
|
||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4">`). `link`, `external`, `disabled`, `type`.
|
||
|
||
### `<x-fab-menu>`, `<x-fab-menu-item>`
|
||
|
||
```blade
|
||
<div class="fixed end-4 bottom-4">
|
||
<x-fab-menu label="New">
|
||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
|
||
<x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
|
||
</x-fab-menu>
|
||
</div>
|
||
```
|
||
|
||
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`.
|
||
|
||
### `<x-loading>`
|
||
|
||
M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless sized or coloured by class; `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion.
|
||
|
||
```blade
|
||
<x-loading />
|
||
<x-loading contained class="size-8" label="Uploading" />
|
||
<div wire:loading.flex wire:target="upload"><x-loading /></div>
|
||
```
|
||
|
||
`<x-button spinner>` shows a decorative one in place of its icon.
|
||
|
||
### `<x-toast>`
|
||
|
||
The snackbar host. Once per layout, near the end of `<body>`: `<x-toast />` (`position="bottom-start"` to leave the centre free). It is `@persist`ed across `wire:navigate` and shows, one at a time, every toast from the `Toasts` concern (see Toasts above) or from JavaScript:
|
||
|
||
```js
|
||
materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } })
|
||
```
|
||
|
||
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
|
||
|
||
- `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON.
|
||
- `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news:
|
||
|
||
```js
|
||
window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, action: { label: 'Reload', event: 'app:update' } } }))
|
||
window.addEventListener('app:update', () => location.reload())
|
||
```
|
||
|
||
- Hooks: `data-toast` on the snackbar on screen, `data-toast-action` on its action button (`[data-toast]` is absent while nothing shows). Target these in tests, not classes.
|
||
|
||
### `<x-progress>`
|
||
|
||
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
||
|
||
| Prop | Default | |
|
||
|---|---|---|
|
||
| `value` | `null` | 0 to `max`, clamped; `null` is indeterminate |
|
||
| `max` | `100` | |
|
||
| `bind` | `null` | an Alpine expression it follows in the browser; `null`/`undefined` is indeterminate |
|
||
| `circular` | `false` | circular instead of linear |
|
||
| `wavy` | `false` | Expressive's wave (flat below 10% and from 95%) |
|
||
| `thick` | `false` | 8px track and indicator instead of 4px |
|
||
| `color` | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info`; the track is the colour's container (secondary-container for primary) |
|
||
| `label` | `"Progress"` | names the `progressbar`; `:label="false"` makes it decorative |
|
||
|
||
```blade
|
||
<x-progress :value="$share->uploaded" :max="$share->size" label="Uploading" />
|
||
<x-progress circular wavy label="Preparing the download" />
|
||
|
||
<div x-data="{ progress: null }" x-on:livewire-upload-progress="progress = $event.detail.progress" x-on:livewire-upload-finish="progress = null">
|
||
<input type="file" wire:model="file">
|
||
<div x-show="progress !== null"><x-progress bind="progress" wavy label="Uploading" /></div>
|
||
</div>
|
||
```
|
||
|
||
A value the server changes animates after a morph (the SVG is `wire:ignore`; only the root's attributes change). Under reduced motion values jump, the wave stands still and an indeterminate indicator holds one frame. A `w-*` class narrows a linear one; never pass a display or position class.
|
||
|
||
### `<x-badge>`
|
||
|
||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`.
|
||
- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline-variant edge with on-surface-variant text.
|
||
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour.
|
||
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
|
||
|
||
### `<x-alert>`
|
||
|
||
A notice in the page, in the state's container colour with its icon:
|
||
|
||
```blade
|
||
<x-alert title="Storage almost full" description="3.8 GB of 4 GB used." color="warning" />
|
||
<x-alert color="error" dismissible>
|
||
The upload failed.
|
||
<x-slot:actions><x-button label="Try again" wire:click="retry" /></x-slot:actions>
|
||
</x-alert>
|
||
```
|
||
|
||
`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Errors and warnings are `role="alert"`, the rest `role="status"`.
|
||
|
||
### `<x-rich-tooltip>`
|
||
|
||
A few lines of context around a trigger, with an optional `title` and `actions` slot:
|
||
|
||
```blade
|
||
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time.">
|
||
<x-button icon="help" aria-label="About expiry" />
|
||
</x-rich-tooltip>
|
||
```
|
||
|
||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
|
||
|
||
### `<x-stat>`
|
||
|
||
`<x-stat title="Shares" value="1,204" icon="link" description="12 this week" />` — a figure on a surface-container panel; the value counts up on first appearance and when it changes. The slot goes under the description (e.g. a quota's `<x-progress>`). Do not pass a `bg-*` class; wrap it.
|
||
|
||
### `<x-empty-state>`
|
||
|
||
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
|
||
|
||
The `illustration` slot draws the application's own artwork in place of the shape and icon (`icon` and `shape` are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its `class` sets the colour `currentColor` takes. Mark decorative SVG `aria-hidden="true"`. A slot holding only whitespace or comments leaves the shape and icon.
|
||
|
||
```blade
|
||
<x-empty-state title="No routes yet" description="Draw one on the map.">
|
||
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 120 120" aria-hidden="true">…</svg></x-slot:illustration>
|
||
<x-slot:actions><x-button label="Draw a route" variant="filled" /></x-slot:actions>
|
||
</x-empty-state>
|
||
```
|
||
|
||
### `<x-card>`
|
||
|
||
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
|
||
|
||
A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link.
|
||
|
||
```blade
|
||
<x-card variant="outlined" data-list-row wire:key="share-{{ $share->id }}">
|
||
<a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate class="type-title-md">{{ $share->name }}</a>
|
||
<x-slot:actions><x-button label="Copy link" wire:click="copy({{ $share->id }})" /></x-slot:actions>
|
||
</x-card>
|
||
```
|
||
|
||
### `<x-list>`, `<x-list-item>`
|
||
|
||
`<x-list>`: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). `<x-list-item>`: `title` (or slot), `overline`, `description`, leading `icon` / `avatar` (image URL or initials) / `image` / `leading` slot, trailing `trailing` text / `icon-right` / `end` slot, `link` (the whole item becomes a row that opens it), `selected`, `disabled`. One-, two- and three-line heights follow from the content.
|
||
|
||
```blade
|
||
<x-list segmented label="Files">
|
||
@foreach ($files as $file)
|
||
<x-list-item :title="$file->name" :description="$file->size" icon="description" wire:key="file-{{ $file->id }}">
|
||
<x-slot:end><x-button icon="download" tooltip="Download" wire:click="download({{ $file->id }})" /></x-slot:end>
|
||
</x-list-item>
|
||
@endforeach
|
||
</x-list>
|
||
```
|
||
|
||
### `<x-divider>`
|
||
|
||
`<x-divider />` — outline-variant line; `vertical`, `inset` (16px start), `middle`, `decorative` (hidden from assistive tech).
|
||
|
||
### `<x-collapse>`
|
||
|
||
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
|
||
|
||
Bind the open state to a boolean, both ways, with `wire:model` (any modifiers; `.live` sends each toggle at once) or `x-model`:
|
||
|
||
```blade
|
||
<x-collapse title="Fine-tuning" wire:model="fineTuning">…</x-collapse>
|
||
<div x-data="{ advanced: false }"><x-collapse title="Advanced" x-model="advanced">…</x-collapse></div>
|
||
```
|
||
|
||
Toggling writes the property; changing the property (in an action or in Alpine) opens or closes it. With `wire:model` the server renders it open or closed as the property is, so there is no flash, and `open` is ignored; with `x-model`, `open` is only the first paint until Alpine starts. The bound state is `collapseOpen` in the `<details>` scope. Without a binding there is no Alpine on it.
|
||
|
||
### `<x-modal>`
|
||
|
||
An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope.
|
||
|
||
```blade
|
||
<x-modal wire:model="deletingId" title="Delete this share?" subtitle="Recipients lose access at once." icon="delete">
|
||
<x-slot:actions>
|
||
<x-button label="Cancel" x-on:click="close()" />
|
||
<x-button label="Delete" danger wire:click="delete" />
|
||
</x-slot:actions>
|
||
</x-modal>
|
||
```
|
||
|
||
Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent` (no Escape or scrim), `fullscreen` (whole screen below `sm`, for forms), `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead.
|
||
|
||
### `<x-drawer>`
|
||
|
||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
|
||
|
||
### `<x-bottom-sheet>`
|
||
|
||
An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`90dvh`), `actions` slot.
|
||
|
||
### `<x-carousel>`, `<x-carousel-item>`
|
||
|
||
```blade
|
||
<x-carousel label="Recent uploads" item-width="220">
|
||
@foreach ($photos as $photo)
|
||
<x-carousel-item :label="$photo->title" wire:key="photo-{{ $photo->id }}">
|
||
<img src="{{ $photo->url }}" alt="{{ $photo->alt }}" />
|
||
</x-carousel-item>
|
||
@endforeach
|
||
</x-carousel>
|
||
```
|
||
|
||
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, 0), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
|
||
|
||
### `<x-chip>`
|
||
|
||
One component for M3's four chips, picked by `type`:
|
||
|
||
| `type` | What it is | Element |
|
||
|---|---|---|
|
||
| `assist` (default) | an action | `<button>`, or `<a>` with `link` |
|
||
| `filter` | a toggle | a native checkbox under the chip with `wire:model`, `x-model` or `name`; otherwise a `<button aria-pressed>` whose `selected` you own |
|
||
| `input` | something a person entered | its own button only with `wire:click`, `x-on:click`, `link` or `selected`; a remove button with `removable` |
|
||
| `suggestion` | a suggested reply or query | `<button>` |
|
||
|
||
Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `disabled`, `link`, `external`, `no-wire-navigate`, `selected` (filter and input), `name` / `value` (a filter checkbox; an input chip's hidden input, `value` defaulting to the label), `avatar` (input: image URL or initials), `removable`, `remove` (input: an Alpine expression), `tooltip`. On a filter checkbox and an input chip, `class`, `style` and `wire:key` stay on the chip and every other attribute goes to the control inside.
|
||
|
||
```blade
|
||
<x-chip label="Add to calendar" icon="event" wire:click="addToCalendar" />
|
||
|
||
<x-chip-set label="File types" hint="Show only these" error-field="kinds">
|
||
@foreach ($kindOptions as $kind => $name)
|
||
<x-chip type="filter" :label="$name" :value="$kind" wire:model.live="kinds" wire:key="kind-{{ $kind }}" />
|
||
@endforeach
|
||
</x-chip-set>
|
||
|
||
<x-chip type="filter" label="Starred" icon="star" :selected="$starredOnly" wire:click="$toggle('starredOnly')" />
|
||
|
||
@foreach ($recipients as $recipient)
|
||
<x-chip type="input" :label="$recipient->email" :avatar="$recipient->initials" removable wire:remove="removeRecipient({{ $recipient->id }})" wire:key="recipient-{{ $recipient->id }}" />
|
||
@endforeach
|
||
```
|
||
|
||
- A multi-select set binds `wire:model` on every chip, each with its own `value`, to an array property; a boolean property needs no `value`. The chips render checked as the property already says.
|
||
- A removable input chip removes through `wire:remove` (it becomes the remove button's `wire:click`), `remove` (Alpine), or, with neither, takes itself off the page. Backspace or Delete on a focused chip removes it and moves focus to the previous or next chip; the remove button is named "Remove <label>". Give each one a `wire:key`.
|
||
|
||
### `<x-chip-set>`
|
||
|
||
A row of chips 8px apart that wraps, as `role="group"`: `label` (shown, and names the group; otherwise pass `aria-label`), `hint`, `error-field` (a validation message for that property or its items replaces the hint), `scroll` (one line that scrolls sideways, fading the edge it can still scroll towards).
|
||
|
||
### `<x-form>`
|
||
|
||
A one-column grid of fields with an `actions` slot at the foot (the slot takes its own `class`); `separator` draws a divider above the actions.
|
||
|
||
```blade
|
||
<x-form wire:submit="save">
|
||
<x-input label="Share name" wire:model="name" required />
|
||
<x-select label="Expires after" wire:model="hours" :options="$expiryOptions" />
|
||
<x-slot:actions>
|
||
<x-button label="Cancel" wire:click="cancel" />
|
||
<x-button label="Create share" variant="filled" type="submit" spinner="save" />
|
||
</x-slot:actions>
|
||
</x-form>
|
||
```
|
||
|
||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||
|
||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||
|
||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||
- `<x-password>`: a reveal button; `icon`, `size`.
|
||
- `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one.
|
||
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`.
|
||
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
|
||
- `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `class="field-control"`; only for controls the package does not have.
|
||
|
||
### `<x-checkbox>`, `<x-radio>`, `<x-toggle>`
|
||
|
||
M3 selection controls on native inputs; the whole row is the label.
|
||
|
||
- `<x-checkbox label hint right indeterminate />` — `indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render).
|
||
- `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group.
|
||
- `<x-toggle label hint right icons />` — M3 switch (`role="switch"`); `icons` puts a check and a cross on the handle, `icons="selected"` only the check. Without `label`, pass `aria-label`.
|
||
|
||
```blade
|
||
<x-checkbox label="All files" :checked="count($selected) === $files->count()" :indeterminate="$selected && count($selected) < $files->count()" wire:click="toggleAll" />
|
||
<x-toggle label="Notify me on download" wire:model.live="notify" right />
|
||
```
|
||
|
||
### `<x-slider>`
|
||
|
||
M3 Expressive's slider on native `<input type="range">`s (one per handle), so the arrow keys, Home, End, forms and screen readers work as on a plain range; PageUp and PageDown move a tenth of the steps (1 to 10). A press anywhere on the slider moves the nearest handle there. Without JavaScript the native range shows, and posts.
|
||
|
||
```blade
|
||
<x-slider label="Volume" wire:model.live="volume" hint="Applies at once" />
|
||
<x-slider label="Price" wire:model="price" range :max="500" :step="10" ticks />
|
||
<x-slider label="Balance" x-model="balance" name="balance" :min="-50" :max="50" centered />
|
||
<x-slider label="Brightness" name="brightness" value="60" size="lg" icon="light_mode" />
|
||
```
|
||
|
||
| Prop | Default | |
|
||
|---|---|---|
|
||
| `label`, `hint` | | the label above names the input (with `range`, the group); a validation error for the bound property or `name` replaces the hint |
|
||
| `value`, `min`, `max`, `step` | `null`, `0`, `100`, `1` | as on a range input; `step="any"` is continuous. With `wire:model` the property's value is drawn |
|
||
| `name` | the `wire:model` property | with `range` it posts `name[]` twice, from first |
|
||
| `range` | `false` | two handles that never cross; binds an array `[from, to]` (`wire:model="price"` binds `price.0` and `price.1`, `x-model="price"` binds `price[0]` and `price[1]`) |
|
||
| `centered` | `false` | fills from the middle of the track, for values that go below zero |
|
||
| `size` | `xs` | track `xs` 16px, `sm` 24px, `md` 40px, `lg` 56px, `xl` 96px |
|
||
| `icon` | `null` | a Material Symbol inside the track, `md` and up, standard sliders only |
|
||
| `ticks` | `false` | a mark per step (up to 200, hidden while closer than 8px); the handle sits on the marks |
|
||
| `value-label` | `drag` | `drag` (while pressed, dragged or keyboard-focused), `always`, `never` |
|
||
| `color` | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` |
|
||
| `disabled` | `false` | |
|
||
|
||
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
|
||
|
||
### `<x-datepicker>`
|
||
|
||
M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire).
|
||
|
||
```blade
|
||
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
|
||
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
|
||
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
|
||
<x-datepicker label="Race day" wire:model="raceDay" :week-start="$user->week_start" :format="$user->date_format" />
|
||
```
|
||
|
||
| Prop | Default | |
|
||
|---|---|---|
|
||
| `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog below `sm`; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
|
||
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field |
|
||
| `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside |
|
||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
|
||
| `value` | `null` | the initial value without `wire:model` |
|
||
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
|
||
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
|
||
| `week-start` | `null` | the first day of the week, `0` (Sunday) to `6` (Saturday), instead of the locale's: the calendar's columns, weekday header and Home/End follow it. Anything else is ignored |
|
||
| `format` | `null` | the typed and displayed format instead of the locale's: `dd`, `MM` and `yyyy`, each once, around one delimiter (`.`, `/`, `-`) — `dd.MM.yyyy`, `dd/MM/yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`. The field, the dialog's text fields, a range and the error message follow it; `wire:model` still stores `Y-m-d`. Anything else is ignored |
|
||
|
||
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()` (the last two unless `week-start` and `format` say otherwise — for a per-person setting). Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||
|
||
### `<x-timepicker>`
|
||
|
||
M3's time picker in a modal dialog, opened from a read-only text field (a press, Enter, Space, ArrowDown or its clock icon). The dial picks the hour, then the minutes, by press or drag; a 24-hour clock puts 12–23 on the inner ring. The keyboard icon switches to two text fields. The arrow keys change the focused dial's value, Home and End go to the ends, Enter confirms; Escape, Cancel or the scrim close it unchanged and give focus back to the field. In a landscape window the dial lies on its side.
|
||
|
||
```blade
|
||
<x-timepicker label="Starts at" wire:model="startsAt" />
|
||
<x-timepicker label="Appointment" wire:model.live="appointmentAt" format="24" step="15" min="08:00" max="17:30" clearable />
|
||
```
|
||
|
||
| Prop | Default | |
|
||
|---|---|---|
|
||
| `wire:model` / `x-model` | | the time as `H:i`, null until chosen; `H:i:s` (a `time` column) is read and written back as `H:i`; nothing is written until OK |
|
||
| `format` | the locale's | `12` or `24`; otherwise the hour cycle of `locale` as `Intl.DateTimeFormat` reports it |
|
||
| `locale` | app locale | the hour cycle, and how the field writes the time |
|
||
| `step` | `1` | minutes between choices (a tap picks fives, or steps when five is not a multiple of the step) |
|
||
| `min`, `max` | | `H:i`, inclusive; `min` later than `max` spans midnight. Outside values are greyed out and refused in the picker — validate on the server as well |
|
||
| `clearable` | `false` | a button that empties the field |
|
||
| `name` | | posts the value from a hidden input |
|
||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's; `required`, `disabled` and `placeholder` reach its input |
|
||
|
||
Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`: a Livewire render leaves an open picker open with its draft. Never name a Livewire property `$slot`: it renders empty in the component's view.
|
||
|
||
### `<x-choices>`
|
||
|
||
Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint.
|
||
|
||
- Default: filter chips, every option on screen — `single` for choice chips.
|
||
- `searchable`: a text field that filters a menu as you type (single value; arrow keys, Enter, Escape); `icon`, `variant`, `placeholder`. Its list is a popover, so it is never clipped by a card.
|
||
|
||
```blade
|
||
<x-choices label="Days you are free" wire:model.live="days" :options="$weekdays" />
|
||
<x-choices label="Time zone" wire:model="timezone" :options="$timezones" searchable icon="public" />
|
||
```
|
||
|
||
Bind with `wire:model` (entangled) or, without Livewire, `x-model`. The options are baked into its Alpine state: when they change on the server, give it a `wire:key` that changes with them.
|
||
|
||
### `<x-search>`
|
||
|
||
M3 search bar that opens into a search view: docked under the bar from `sm`, full screen with a back arrow below (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`; `trailing` slot (avatar, icon buttons).
|
||
|
||
```blade
|
||
<x-search wire:model.live.debounce.300ms="query" placeholder="Search shares">
|
||
@foreach ($this->results as $share)
|
||
<x-list-item :title="$share->name" :description="$share->size" link="{{ route('shares.show', $share) }}" wire:key="result-{{ $share->id }}" />
|
||
@endforeach
|
||
<x-slot:empty>No shares match.</x-slot:empty>
|
||
</x-search>
|
||
```
|
||
|
||
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it.
|
||
|
||
### `<x-app-shell>`
|
||
|
||
The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a collapsed rail that opens as a modal to `lg`, an expanded rail the visitor can collapse from `lg`, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
|
||
|
||
```blade
|
||
<x-app-shell :destinations="[
|
||
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
|
||
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
|
||
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
|
||
]">
|
||
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||
<x-slot:rail-header>
|
||
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
|
||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
|
||
</x-slot:rail-header>
|
||
<x-slot:rail-footer>
|
||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
|
||
</x-slot:rail-footer>
|
||
<x-slot:top>
|
||
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
|
||
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
|
||
</x-slot:top>
|
||
|
||
{{ $slot }}
|
||
</x-app-shell>
|
||
```
|
||
|
||
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
||
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
|
||
- Below `sm` the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas.
|
||
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
|
||
|
||
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
||
|
||
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does):
|
||
|
||
```blade
|
||
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||
<x-navigation-bar>
|
||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
|
||
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
|
||
</x-navigation-bar>
|
||
</div>
|
||
```
|
||
|
||
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
|
||
|
||
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
|
||
|
||
M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).
|
||
|
||
```blade
|
||
<div class="flex min-h-dvh">
|
||
<x-navigation-rail mode="collapsible">
|
||
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
|
||
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
|
||
|
||
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
|
||
<x-navigation-rail-section label="Admin">
|
||
<x-navigation-rail-item label="Users" icon="group" link="{{ route('users') }}" />
|
||
</x-navigation-rail-section>
|
||
|
||
<x-slot:footer>
|
||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" />
|
||
</x-slot:footer>
|
||
</x-navigation-rail>
|
||
|
||
<main class="min-w-0 flex-1">…</main>
|
||
</div>
|
||
```
|
||
|
||
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-app-shell>`'s: hidden and opened as a modal below `sm`, collapsed and opened as a modal to `lg`, collapsible from `lg`).
|
||
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
|
||
- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
|
||
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
|
||
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
|
||
|
||
### `<x-app-bar>`
|
||
|
||
M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away), turning surface-container once content scrolls under it. `variant`: `small` (default), `center`, `medium` and `large` (a big title that collapses into the row as the page scrolls — CSS sticky, no layout shift), `search` (put an `<x-search>` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar).
|
||
|
||
```blade
|
||
<x-app-bar variant="medium" title="Recipients" subtitle="3 people">
|
||
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" :link="route('shares.index')" /></x-slot:navigation>
|
||
<x-slot:actions><x-button icon="person_add" tooltip="Add recipient" wire:click="add" /></x-slot:actions>
|
||
</x-app-bar>
|
||
```
|
||
|
||
A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/`overflow-auto` (`overflow-x-clip` is fine).
|
||
|
||
### `<x-toolbar>`
|
||
|
||
M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `variant`: `floating` (default pill at elevation 3; `vibrant`, `vertical`) or `docked` (full-width surface-container bar). `place`: `bottom` or `end` to fix it over the page; `fab` slot sets a FAB beside a floating toolbar; `label` names it.
|
||
|
||
```blade
|
||
<x-toolbar label="Selection" place="bottom" vibrant>
|
||
<x-button icon="download" tooltip="Download" wire:click="download" />
|
||
<x-button icon="delete" tooltip="Delete" wire:click="delete" />
|
||
<x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab>
|
||
</x-toolbar>
|
||
```
|
||
|
||
### `<x-tabs>`, `<x-tab>`
|
||
|
||
M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skipped, the indicator moves in a view transition). `tabs`: `['name', 'label', 'icon', 'badge', 'disabled']`; panels are `<x-tab name>` in the slot. Bind with `wire:model` (entangled), or `selected` / `x-model` without Livewire. `variant` `primary` (default) or `secondary`; `stacked` (icon over label), `scrollable`. Give two identical tab sets on one page distinct `id`s.
|
||
|
||
```blade
|
||
<x-tabs wire:model.live="tab" :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People', 'badge' => $pending]]">
|
||
<x-tab name="files">…</x-tab>
|
||
<x-tab name="people">…</x-tab>
|
||
</x-tabs>
|
||
```
|
||
|
||
### `<x-section-nav>`
|
||
|
||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
|
||
|
||
### `<x-account-menu>`
|
||
|
||
An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; default the name's initials), items in the slot, a theme item (`:theme="false"` to drop it), and a `footer` slot for signing out. `label`, `position`.
|
||
|
||
```blade
|
||
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email">
|
||
<x-menu-item label="Settings" icon="settings" :link="route('settings')" />
|
||
<x-slot:footer>
|
||
<form method="POST" action="{{ route('logout') }}">@csrf<x-menu-item label="Sign out" icon="logout" type="submit" /></form>
|
||
</x-slot:footer>
|
||
</x-account-menu>
|
||
```
|
||
|
||
### `<x-theme-toggle>`
|
||
|
||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
|
||
|
||
### `<x-scheme-picker>`
|
||
|
||
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
|
||
|
||
```blade
|
||
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
|
||
```
|
||
|
||
### `<x-table>`, `<x-sort-header>`
|
||
|
||
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
|
||
|
||
`<x-sort-header column="size" :sort-by="$sortBy">Size</x-sort-header>` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`.
|
||
|
||
```blade
|
||
<div class="overflow-x-auto">
|
||
<x-table>
|
||
<thead><tr><x-sort-header column="name" :sort-by="$sortBy">Name</x-sort-header><th class="text-end">Size</th></tr></thead>
|
||
<tbody>
|
||
@foreach ($shares as $share)
|
||
<tr data-list-row wire:key="share-{{ $share->id }}">
|
||
<td><a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate>{{ $share->name }}</a></td>
|
||
<td class="text-end tabular-nums">{{ $share->size }}</td>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
</x-table>
|
||
</div>
|
||
{{ $shares->links() }}
|
||
```
|
||
|
||
Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with `config('livewire-material.pagination')` = `false`; published `vendor/pagination` or `vendor/livewire` views still win.
|
||
|
||
## Testing the design
|
||
|
||
```php
|
||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||
|
||
it('uses only what compiles', function () {
|
||
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||
->forbidColours(['tertiary']) // roles this application's rules leave out
|
||
->violations())->toBe([]);
|
||
});
|
||
```
|
||
|
||
It fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with `path:line` for each.
|
||
|
||
## Conventions
|
||
|
||
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
|
||
- Write class names out whole. Tailwind cannot compile `'text-'.$tone` or `type-{{ $size }}`, and the design guard cannot read them.
|
||
- The showcase at `/material` (local only, `MATERIAL_SHOWCASE=true` to force it) renders every token and component.
|
||
|
||
## Livewire traps
|
||
|
||
- Blade directives do not compile inside a component tag's attributes: `<x-foo x-show="ok(@js($value))">` reaches the browser as literal text. On a component tag use `{{ }}` and `:prop` bindings, or put the Alpine on a plain element inside the slot.
|
||
- Never pass `hidden`, a display utility or a position (`absolute`, `relative`) to a component: it is merged beside the component's own and whichever Tailwind emits last wins. Wrap the component in an element that carries it. A variant that only hides (`max-sm:hidden`) is safe.
|
||
- `$attributes->wire('model')->value()` is `false`, not `null`, when there is no `wire:model`, and `filled(false)` is true. Normalise with `?: null`.
|
||
- End every statement in a multi-line Alpine attribute with `;`: an inline `@if … @endif` inside it swallows the newline after it.
|