Add chips, choices and search
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
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 08:07:59 +02:00
co-authored by Claude Opus 5
parent 7f92f1cb29
commit 0fee2a0952
21 changed files with 2061 additions and 2 deletions
@@ -361,6 +361,42 @@ An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page,
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.
@@ -400,6 +436,35 @@ M3 selection controls on native inputs; the whole row is the label.
<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