tests / lint (push) Successful in 1m4s
tests / feature (8.4) (push) Successful in 1m8s
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (chrome, chromium) (push) Successful in 3m6s
tests / browser (firefox, firefox) (push) Successful in 3m45s
tests / browser (safari, webkit) (push) Successful in 5m0s
M3's assist, filter, input and suggestion chips with chip sets; choices as filter chips or a searchable combobox whose list is an anchored popover; and the search bar that opens into a docked or full-screen search view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
494 lines
31 KiB
Markdown
494 lines
31 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`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
|
|
|
## 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-…`. Never assemble `text-*`, `leading-*` and `tracking-*` by hand. 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`. 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.
|
|
|
|
## 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).
|
|
|
|
## 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`.
|
|
|
|
### `<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), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. 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`, `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 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.
|
|
|
|
### `<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="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
|
|
|
### `<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). `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.
|
|
|
|
### `<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.
|
|
|
|
### `<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">`. 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 read their errors from the bag under the `wire:model` name (the error replaces the hint, 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-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.
|
|
|
|
## 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.
|
|
- 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.
|