diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md
index 49d6c7e5..088cb690 100644
--- a/resources/boost/skills/livewire-material-development/SKILL.md
+++ b/resources/boost/skills/livewire-material-development/SKILL.md
@@ -361,6 +361,42 @@ An M3 bottom sheet, bound like ``: 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). ``: `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). ``: slot is an ` ` (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.
+### ``
+
+One component for M3's four chips, picked by `type`:
+
+| `type` | What it is | Element |
+|---|---|---|
+| `assist` (default) | an action | ``, or `` with `link` |
+| `filter` | a toggle | a native checkbox under the chip with `wire:model`, `x-model` or `name`; otherwise a `` 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 | `` |
+
+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
+
+
+
+ @foreach ($kindOptions as $kind => $name)
+
+ @endforeach
+
+
+
+
+@foreach ($recipients as $recipient)
+
+@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 ". Give each one a `wire:key`.
+
+### ``
+
+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).
+
### ``
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.
```
+### ``
+
+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
+
+
+```
+
+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.
+
+### ``
+
+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
+
+ @foreach ($this->results as $share)
+
+ @endforeach
+ No shares match.
+
+```
+
+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
diff --git a/resources/css/components/field.css b/resources/css/components/field.css
index 83389092..bee770b6 100644
--- a/resources/css/components/field.css
+++ b/resources/css/components/field.css
@@ -221,6 +221,11 @@
cursor: default;
}
+ /* A combobox's arrow turns over while its list is open, as a select's does. */
+ .field:has(.field-control[aria-expanded="true"]) .field-arrow {
+ rotate: 180deg;
+ }
+
.field-arrow {
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
diff --git a/resources/css/components/menu.css b/resources/css/components/menu.css
index 35631775..e03404d0 100644
--- a/resources/css/components/menu.css
+++ b/resources/css/components/menu.css
@@ -29,6 +29,25 @@
box-shadow: var(--md-sys-elevation-2);
}
+/* As a popover (``), it hangs under its field, as wide, and goes above only
+ when there is no room below. */
+.field-menu[popover] {
+ inset: auto;
+ top: anchor(bottom);
+ left: anchor(left);
+ width: anchor-size(width);
+ margin: 0.25rem 0;
+ border: 0;
+ padding-inline: 0;
+ color: var(--md-sys-color-on-surface);
+ position-try-fallbacks: flip-block;
+}
+
+.field-option[aria-disabled="true"] {
+ color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
+ cursor: default;
+}
+
.field-option {
display: flex;
align-items: center;
diff --git a/resources/css/components/search.css b/resources/css/components/search.css
new file mode 100644
index 00000000..e87983b3
--- /dev/null
+++ b/resources/css/components/search.css
@@ -0,0 +1,182 @@
+/*
+ * M3's search: a search bar that opens into a search view (SearchBarTokens, SearchViewTokens,
+ * androidx Compose Material 3, Apache-2.0).
+ *
+ * The bar is a 56px pill in surface-container-high: a leading search icon, the input in
+ * body-large, a clear button once something is typed, and whatever the caller trails it with (an
+ * avatar, an icon button). Focus opens the view. On a medium or wider window the view is docked:
+ * the bar grows down into an extra-large-cornered container at elevation 3 that holds the
+ * results. On a compact window it takes the whole screen, and the search icon turns into a back
+ * arrow. `docked` keeps it docked at every width.
+ *
+ * [data-search] the root; data-open, data-full-screen
+ * [data-search-bar] the pill, above the view
+ * [data-search-leading], [data-search-input], [data-search-clear], [data-search-trailing]
+ * [data-search-view] the container behind the bar
+ * [data-search-results]
+ */
+
+@layer components {
+ [data-search] {
+ --search-height: 3.5rem;
+ }
+
+ [data-search][data-open] {
+ z-index: 50;
+ }
+
+ [data-search-bar] {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ height: var(--search-height);
+ padding-inline: 0.25rem;
+ border-radius: var(--md-sys-shape-corner-full);
+ background-color: var(--md-sys-color-surface-container-high);
+ color: var(--md-sys-color-on-surface);
+ cursor: text;
+ transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
+ }
+
+ @media (hover: hover) {
+ [data-search]:not([data-open]) [data-search-bar]:hover {
+ background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, var(--md-sys-color-surface-container-high));
+ }
+ }
+
+ [data-search][data-open] [data-search-bar] {
+ background-color: transparent;
+ }
+
+ [data-search-leading],
+ [data-search-clear] {
+ display: grid;
+ flex: none;
+ place-items: center;
+ width: 3rem;
+ height: 3rem;
+ border-radius: var(--md-sys-shape-corner-full);
+ }
+
+ [data-search-leading] {
+ color: var(--md-sys-color-on-surface);
+ }
+
+ [data-search-clear] {
+ color: var(--md-sys-color-on-surface-variant);
+ cursor: pointer;
+ }
+
+ [data-search-bar] button {
+ outline: none;
+ transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
+ }
+
+ @media (hover: hover) {
+ [data-search-bar] button:hover {
+ background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent);
+ }
+ }
+
+ [data-search-bar] button:focus-visible {
+ background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
+ outline: 3px solid var(--md-sys-color-secondary);
+ outline-offset: -3px;
+ }
+
+ [data-search-input] {
+ flex: 1 1 0%;
+ min-width: 0;
+ height: 100%;
+ padding-inline: 0.25rem;
+ appearance: none;
+ background: transparent;
+ color: var(--md-sys-color-on-surface);
+ caret-color: var(--md-sys-color-primary);
+ outline: none;
+ font: var(--md-sys-typescale-body-lg);
+ letter-spacing: var(--md-sys-typescale-body-lg-tracking);
+ }
+
+ [data-search-input]::placeholder {
+ color: var(--md-sys-color-on-surface-variant);
+ opacity: 1;
+ }
+
+ [data-search-input]::-webkit-search-cancel-button,
+ [data-search-input]::-webkit-search-decoration {
+ appearance: none;
+ display: none;
+ }
+
+ [data-search-bar]:has([data-search-input]:placeholder-shown) [data-search-clear] {
+ display: none;
+ }
+
+ [data-search-trailing] {
+ display: flex;
+ flex: none;
+ align-items: center;
+ gap: 0.25rem;
+ padding-inline-end: 0.25rem;
+ }
+
+ [data-search][data-open] [data-search-trailing] {
+ display: none;
+ }
+
+ [data-search-view] {
+ position: absolute;
+ inset-inline: 0;
+ top: 0;
+ display: flex;
+ flex-direction: column;
+ max-height: min(40rem, 70dvh);
+ padding-top: var(--search-height);
+ overflow: hidden;
+ border-radius: var(--md-sys-shape-corner-xl);
+ background-color: var(--md-sys-color-surface-container-high);
+ box-shadow: var(--md-sys-elevation-3);
+ transform-origin: top;
+ transition-property: opacity, scale, display;
+ transition-duration: var(--md-sys-motion-spatial-fast-duration);
+ transition-timing-function: var(--md-sys-motion-spatial-fast);
+ transition-behavior: allow-discrete;
+ }
+
+ @starting-style {
+ [data-search-view] {
+ opacity: 0;
+ scale: 1 0.9;
+ }
+ }
+
+ [data-search-results] {
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ border-top: 1px solid var(--md-sys-color-outline);
+ padding-block: 0.5rem;
+ }
+
+ /* Full screen, on a compact window. */
+ [data-search][data-full-screen] [data-search-bar] {
+ position: fixed;
+ inset: 0 0 auto;
+ height: calc(4.5rem + env(safe-area-inset-top));
+ padding-top: env(safe-area-inset-top);
+ padding-inline: 0.25rem;
+ border-radius: 0;
+ }
+
+ [data-search][data-full-screen] [data-search-view] {
+ position: fixed;
+ inset: 0;
+ max-height: none;
+ padding-top: calc(4.5rem + env(safe-area-inset-top));
+ border-radius: 0;
+ box-shadow: none;
+ }
+}
diff --git a/resources/css/material.css b/resources/css/material.css
index eb0209df..dd6385ba 100644
--- a/resources/css/material.css
+++ b/resources/css/material.css
@@ -25,6 +25,7 @@
@import './components/field.css';
@import './components/menu.css';
@import './components/selection.css';
+@import './components/search.css';
@layer base {
html {
diff --git a/resources/js/chips.js b/resources/js/chips.js
new file mode 100644
index 00000000..c9e1197c
--- /dev/null
+++ b/resources/js/chips.js
@@ -0,0 +1,157 @@
+/**
+ * `materialChip`: a removable ``. `materialChipSet`: a scrolling ``.
+ *
+ * Removing always goes through the chip's remove button, so a Backspace or Delete runs exactly
+ * what a press on it runs — its `wire:click` (from `wire:remove`), its Alpine expression, or the
+ * chip taking itself off the page. Before that, focus moves to the chip before it (Backspace) or
+ * after it (Delete), because a focused element that disappears leaves focus on the body.
+ *
+ * A scrolling set marks the edges it can still scroll towards (`data-scroll-start`,
+ * `data-scroll-end`), and the set fades those edges. Its row carries `wire:ignore.self`, so a
+ * Livewire morph keeps the marks.
+ */
+const CONTROLS = 'button:not(:disabled), a[href]:not([tabindex="-1"]), input:not(:disabled):not([type="hidden"])'
+
+document.addEventListener('alpine:init', () => {
+ window.Alpine.data('materialChip', () => ({
+ init() {
+ const button = this.removeButton()
+ const label = this.$root.querySelector('[data-chip-label]')
+
+ // A chip rendered by x-for has no label on the server: its button carries the translated
+ // words with a `:label` placeholder, filled in once Alpine has written the label.
+ if (button?.dataset.chipRemove && label) {
+ this.$nextTick(() => {
+ const text = label.textContent.trim()
+
+ if (text !== '' && !button.hasAttribute('aria-label')) {
+ button.setAttribute('aria-label', button.dataset.chipRemove.replace(':label', text))
+ }
+ })
+ }
+ },
+
+ removeButton() {
+ return this.$root.querySelector('[data-chip-remove]')
+ },
+
+ removeOnKey(event) {
+ if ((event.key !== 'Backspace' && event.key !== 'Delete') || event.ctrlKey || event.metaKey || event.altKey) {
+ return
+ }
+
+ const button = this.removeButton()
+
+ if (!button || button.disabled) {
+ return
+ }
+
+ event.preventDefault()
+ this.handOffFocus(event.key === 'Backspace')
+ button.click()
+ },
+
+ removeChip() {
+ if (this.$root.contains(document.activeElement)) {
+ this.handOffFocus(false)
+ }
+ },
+
+ handOffFocus(backwards) {
+ const chip = this.$root
+ const set = chip.closest('[data-chip-set]') ?? chip.parentElement
+ const chips = [...(set?.querySelectorAll('[data-chip]') ?? [])]
+ const index = chips.indexOf(chip)
+ const before = chips.slice(0, index).reverse()
+ const after = chips.slice(index + 1)
+ const onRemove = document.activeElement?.hasAttribute('data-chip-remove')
+
+ for (const other of backwards ? [...before, ...after] : [...after, ...before]) {
+ const target = (onRemove && other.querySelector('[data-chip-remove]:not(:disabled)')) || (other.matches(CONTROLS) ? other : other.querySelector(CONTROLS))
+
+ if (target) {
+ target.focus()
+ this.holdFocus(target, set)
+
+ return
+ }
+ }
+ },
+
+ // Livewire morphs without lookahead: removing a chip re-creates every keyed chip after it,
+ // and the focused one goes with them. Until the person moves focus themselves (or ten
+ // seconds pass), whenever the set changes and focus has fallen away, focus the same
+ // control again — the element itself, or the chip with its `wire:key` after a morph.
+ holdFocus(target, set) {
+ const key = target.closest('[data-chip]')?.getAttribute('wire:key')
+ const onRemove = target.hasAttribute('data-chip-remove')
+
+ const current = () => {
+ if (target.isConnected || !key) {
+ return target.isConnected ? target : null
+ }
+
+ const chip = [...set.querySelectorAll('[data-chip]')].find((other) => other.getAttribute('wire:key') === key)
+
+ return chip && (onRemove ? chip.querySelector('[data-chip-remove]') : chip.matches(CONTROLS) ? chip : chip.querySelector(CONTROLS))
+ }
+
+ const observer = new MutationObserver(() => {
+ const active = document.activeElement
+ const control = current()
+
+ if (!control || (active && active !== document.body && active !== control)) {
+ stop()
+ } else if (active !== control) {
+ control.focus()
+ }
+ })
+
+ const timer = setTimeout(() => stop(), 10000)
+
+ const stop = () => {
+ observer.disconnect()
+ clearTimeout(timer)
+ }
+
+ observer.observe(set, { childList: true, subtree: true })
+ },
+ }))
+
+ window.Alpine.data('materialChipSet', () => ({
+ observers: [],
+
+ init() {
+ const row = this.$el
+
+ const mark = () => {
+ // scrollLeft runs negative towards the end in a right-to-left row.
+ const travelled = Math.abs(row.scrollLeft)
+ const room = row.scrollWidth - row.clientWidth
+
+ row.toggleAttribute('data-scroll-start', travelled > 1)
+ row.toggleAttribute('data-scroll-end', room - travelled > 1)
+ }
+
+ // A filter chip's check changes its width without resizing the row.
+ for (const type of ['scroll', 'transitionend']) {
+ row.addEventListener(type, mark, { passive: true })
+ this.observers.push(() => row.removeEventListener(type, mark))
+ }
+
+ const resize = new ResizeObserver(mark)
+ resize.observe(row)
+ this.observers.push(() => resize.disconnect())
+
+ const mutation = new MutationObserver(mark)
+ mutation.observe(row, { childList: true, subtree: true })
+ this.observers.push(() => mutation.disconnect())
+
+ mark()
+ },
+
+ destroy() {
+ this.observers.forEach((stop) => stop())
+ },
+ }))
+})
diff --git a/resources/js/material.js b/resources/js/material.js
index 44d05631..4efe2321 100644
--- a/resources/js/material.js
+++ b/resources/js/material.js
@@ -17,4 +17,6 @@ import './progress.js'
import './list-rows.js'
import './bottom-sheet.js'
import './carousel.js'
+import './chips.js'
import './field.js'
+import './search.js'
diff --git a/resources/js/search.js b/resources/js/search.js
new file mode 100644
index 00000000..4d8aec73
--- /dev/null
+++ b/resources/js/search.js
@@ -0,0 +1,99 @@
+/**
+ * `materialSearch`: the behaviour of `` — a search bar that opens into a search view.
+ *
+ * Focus or a press on the bar opens the view; Escape, the back arrow, a press outside, focus
+ * leaving the search, or choosing a result closes it. ArrowDown from the input moves into the
+ * results and the arrow keys walk them; ArrowUp past the first result returns to the input. On a
+ * compact window (below `sm`) the view is full screen and modal: focus stays in it and the page
+ * behind does not scroll. The results themselves are the caller's, rendered by Livewire into the
+ * view as the query changes.
+ */
+const COMPACT = '(max-width: 39.99rem)'
+
+const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
+
+const CHOOSES = 'a[href], button:not([disabled]), [data-list-open]'
+
+// A close hands focus back to the input: Escape does, and so does the full-screen view's focus trap
+// when it lets go, a moment later. Focus arriving this soon after a close is that, not someone
+// coming to search.
+const RETURN_GUARD_MS = 250
+
+document.addEventListener('alpine:init', () => {
+ window.Alpine.data('materialSearch', (docked = false) => ({
+ open: false,
+ compact: false,
+ closedAt: -Infinity,
+
+ init() {
+ const query = window.matchMedia(COMPACT)
+
+ this.compact = query.matches
+ query.addEventListener('change', (event) => (this.compact = event.matches))
+ },
+
+ get fullScreen() {
+ return this.open && this.compact && !docked
+ },
+
+ show() {
+ this.open = true
+ },
+
+ focused() {
+ if (performance.now() - this.closedAt > RETURN_GUARD_MS) {
+ this.show()
+ }
+ },
+
+ close(refocus = false) {
+ this.open = false
+ this.closedAt = performance.now()
+
+ if (refocus) {
+ this.$refs.input.focus()
+ }
+ },
+
+ clear() {
+ const input = this.$refs.input
+
+ input.value = ''
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ input.focus()
+ },
+
+ results() {
+ return [...this.$refs.view.querySelectorAll(FOCUSABLE)].filter((element) => element.getClientRects().length > 0)
+ },
+
+ step(delta) {
+ const results = this.results()
+ const index = results.indexOf(document.activeElement)
+
+ if (results.length === 0) {
+ return
+ }
+
+ if (index === -1) {
+ results[delta > 0 ? 0 : results.length - 1].focus()
+ } else if (index + delta < 0) {
+ this.$refs.input.focus()
+ } else {
+ results[Math.min(index + delta, results.length - 1)].focus()
+ }
+ },
+
+ choose(event) {
+ if (event.target.closest(CHOOSES)) {
+ this.close()
+ }
+ },
+
+ leave(event) {
+ if (event.relatedTarget && !this.$root.contains(event.relatedTarget)) {
+ this.close()
+ }
+ },
+ }))
+})
diff --git a/resources/views/components/chip-set.blade.php b/resources/views/components/chip-set.blade.php
new file mode 100644
index 00000000..d334b72c
--- /dev/null
+++ b/resources/views/components/chip-set.blade.php
@@ -0,0 +1,65 @@
+{{-- A set of M3 chips: a row that wraps, 8px apart, named for screen readers as a group.
+
+
+
+
+
+
+ `label` is shown above the row and names the group; without it, pass `aria-label`. `hint`
+ sits under it, and a validation message for `error-field` (the property the chips bind, and
+ its items: `kinds` and `kinds.*`) replaces the hint.
+
+ `scroll` keeps the chips on one line that scrolls sideways, as M3 lays chips out on a narrow
+ screen: the edge it can still scroll towards fades (resources/js/chips.js), and a chip reached
+ with Tab scrolls clear of the fade. Removing a focused input chip moves focus within the set. --}}
+
+@props([
+ 'label' => null,
+ 'hint' => null,
+ 'errorField' => null,
+ 'scroll' => false,
+])
+
+@php
+ $key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
+ $messages = filled($errorField) && isset($errors)
+ ? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorField), $errors->get($errorField.'.*')])))
+ : [];
+ $describedBy = $messages !== [] || filled($hint) ? "material-chip-set-{$key}-hint" : null;
+@endphp
+
+class('min-w-0') }}
+>
+ @if (filled($label))
+
{{ $label }}
+ @endif
+
+ @if ($scroll)
+
+ {{ $slot }}
+
+ @else
+
+ {{ $slot }}
+
+ @endif
+
+ @if ($messages !== [])
+
+ @foreach ($messages as $message)
+
{{ $message }}
+ @endforeach
+
+ @elseif (filled($hint))
+
{{ $hint }}
+ @endif
+
diff --git a/resources/views/components/chip.blade.php b/resources/views/components/chip.blade.php
new file mode 100644
index 00000000..8228708a
--- /dev/null
+++ b/resources/views/components/chip.blade.php
@@ -0,0 +1,298 @@
+{{-- An M3 chip — assist, filter, input or suggestion — in one component, picked by `type`.
+
+
+
+
+
+
+ `assist` (the default) is an action: a button, or an anchor with `link` (`wire:navigate` unless
+ `external` or `no-wire-navigate`; `disabled` works on a link too, as `aria-disabled`). Its icons
+ are primary. `suggestion` is the same in on-surface-variant, for a suggested reply or query.
+
+ `filter` is a toggle. With `wire:model`, `x-model` or `name` it is a native checkbox under the
+ chip, so Livewire and Alpine bind it as they bind any checkbox — to a boolean, or to an array
+ with `value` for a multi-select set — and a form submits it; `selected` is then only the
+ initial state without a model. Without any of them it is a toggle button whose `selected`
+ (`aria-pressed`) the caller owns, flipped by its own `wire:click`. Selected, it takes
+ secondary-container and a check that grows in at the start; with an `icon`, the check takes the
+ icon's place.
+
+ `input` is something a person entered: `avatar` (an image URL, or initials) or `icon` at the
+ start, `selected`, and `removable` for a trailing remove button named "Remove ". Removing
+ calls `wire:remove` (a Livewire action) or `remove` (an Alpine expression); with neither, the
+ chip takes itself off the page, hidden `name`/`value` input and all. Backspace or Delete on a
+ focused chip removes it too, and focus moves to the previous (Backspace) or next (Delete) chip.
+ The chip is a button only when it has something of its own to do (`wire:click`, `x-on:click`,
+ `link`, `selected`); otherwise the remove button is its one stop. Give a removable chip in a
+ Livewire loop a `wire:key`.
+
+ `elevated` draws assist, filter and suggestion chips on surface-container-low at elevation 1
+ instead of the outline; input chips are flat only, as in M3. `tooltip` attaches a plain tooltip.
+
+ Attributes: 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 (the checkbox, or the chip's button).
+
+ Values from androidx Compose Material 3 (Chip.kt, AssistChipTokens, FilterChipTokens,
+ InputChipTokens, SuggestionChipTokens at androidx/androidx 27cf9a7, Apache-2.0): 32px tall,
+ small corner, label-large, 18px icons, 24px avatar; 16px padding beside a label, 8px beside an
+ icon, 8px between (an input chip: 12px, 8px, 4px beside an avatar). The check grows in on the
+ fast spatial spring and fades in on the slow effects one; it shrinks on default effects and
+ fades on fast effects, as AnimatingChipContent does. --}}
+
+@props([
+ 'type' => 'assist',
+ 'label' => null,
+ 'icon' => null,
+ 'iconRight' => null,
+ 'avatar' => null,
+ 'elevated' => false,
+ 'selected' => null,
+ 'removable' => false,
+ 'remove' => null,
+ 'link' => null,
+ 'external' => false,
+ 'noWireNavigate' => false,
+ 'disabled' => false,
+ 'name' => null,
+ 'value' => null,
+ 'tooltip' => null,
+])
+
+@php
+ $kind = in_array($type, ['assist', 'filter', 'input', 'suggestion'], true) ? $type : 'assist';
+ $text = $label ?? trim(strip_tags((string) $slot));
+ $isLink = filled($link) && $kind !== 'filter';
+ $inert = $disabled && $isLink;
+ $raised = $elevated && $kind !== 'input';
+ $initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.');
+ $anchor = $tooltip !== null ? '--material-chip-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
+
+ // A filter chip is a native checkbox whenever something binds or submits it.
+ $checkbox = $kind === 'filter' && ($attributes->whereStartsWith(['wire:model', 'x-model'])->isNotEmpty() || filled($name));
+ $wireModel = $attributes->whereStartsWith('wire:model')->first();
+ $checked = (bool) $selected;
+
+ // Rendered as the bound property already says, so nothing flips (and animates) once Livewire starts.
+ if ($checkbox && $wireModel !== null && ($component = \Livewire\Livewire::current()) !== null) {
+ $bound = data_get($component, $wireModel);
+ $checked = is_array($bound)
+ ? in_array((string) $value, array_map(fn ($item): string => is_scalar($item) ? (string) $item : '', $bound), true)
+ : (bool) $bound;
+ }
+
+ $wireRemove = $attributes->get('wire:remove');
+ $attributes = $attributes->except(['wire:remove']);
+
+ $outer = ['class', 'style', 'wire:key'];
+
+ // Compose draws the 1px border inside the chip; a CSS border takes room, so the padding
+ // beside it is 1px short of the token (7px for 8, 15px for 16).
+
+ $base = 'group/chip inline-flex h-8 max-w-full shrink-0 select-none items-center whitespace-nowrap rounded-corner-sm border type-label-lg transition-[background-color,border-color,color,box-shadow] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast';
+ // Below 48px the touch target reaches past the chip, as M3 requires.
+ $target = 'after:absolute after:inset-x-0 after:top-1/2 after:h-12 after:-translate-y-1/2';
+
+ // Every colour class written out whole, so Tailwind compiles it. Selected is a state the browser
+ // owns on a filter chip (a checked checkbox, or aria-pressed), so it is written as a variant.
+ $colours = match (true) {
+ $kind === 'filter' && $raised && $disabled => 'border-transparent bg-on-surface/12 text-on-surface/38',
+ $kind === 'filter' && $raised => 'border-transparent bg-surface-container-low text-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2 has-checked:bg-secondary-container has-checked:text-on-secondary-container aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container',
+ $kind === 'filter' && $disabled => 'border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12 aria-pressed:border-transparent aria-pressed:bg-on-surface/12',
+ $kind === 'filter' => 'border-outline-variant text-on-surface-variant focus-visible:border-on-surface-variant has-focus-visible:border-on-surface-variant has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container hover:has-checked:shadow-elevation-1 aria-pressed:border-transparent aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container hover:aria-pressed:shadow-elevation-1',
+ $kind === 'input' && $disabled => $selected ? 'border-transparent bg-on-surface/12 text-on-surface/38' : 'border-on-surface/12 text-on-surface/38',
+ $kind === 'input' => $selected ? 'border-transparent bg-secondary-container text-on-secondary-container' : 'border-outline-variant text-on-surface-variant has-[[data-chip-action]:focus-visible]:border-on-surface-variant',
+ $raised && $disabled => 'border-transparent bg-on-surface/12 text-on-surface/38',
+ $raised => $kind === 'suggestion'
+ ? 'border-transparent bg-surface-container-low text-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2'
+ : 'border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2',
+ $disabled => 'border-on-surface/12 text-on-surface/38',
+ default => $kind === 'suggestion'
+ ? 'border-outline-variant text-on-surface-variant focus-visible:border-on-surface-variant'
+ : 'border-outline-variant text-on-surface focus-visible:border-on-surface',
+ };
+
+ // Leading icons: primary on assist, suggestion and filter chips; on-surface-variant on an input
+ // chip, primary while it is hovered, focused or pressed, and primary on a selected one.
+ $clicks = $attributes->whereStartsWith(['wire:click', 'x-on:click', '@click'])->isNotEmpty();
+ $leadingInk = match (true) {
+ $disabled => null,
+ $kind !== 'input', (bool) $selected => 'text-primary',
+ $isLink || $clicks => 'transition-colors duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-hover/chip:text-primary group-active/chip:text-primary group-has-focus-visible/chip:text-primary',
+ default => null,
+ };
+ $trailingInk = in_array($kind, ['assist', 'suggestion'], true) && ! $disabled ? 'text-primary' : null;
+
+ $check = 'size-4.5 opacity-0 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-100 group-has-checked/chip:duration-(--md-sys-motion-effects-slow-duration) group-has-checked/chip:ease-effects-slow group-aria-pressed/chip:opacity-100 group-aria-pressed/chip:duration-(--md-sys-motion-effects-slow-duration) group-aria-pressed/chip:ease-effects-slow';
+
+ if ($kind === 'input') {
+ $actionTag = match (true) {
+ $isLink => 'a',
+ $clicks || $selected !== null => 'button',
+ default => 'span',
+ };
+ $interactive = $actionTag !== 'span';
+ $removeExpression = implode('; ', array_filter([
+ 'removeChip($event)',
+ filled($remove) ? $remove : null,
+ blank($remove) && blank($wireRemove) ? '$root.remove()' : null,
+ ]));
+
+ $container = $attributes->only($outer)
+ ->class([
+ $base,
+ 'relative isolate',
+ $colours,
+ 'has-[[data-chip-action]:focus-visible]:outline-3 has-[[data-chip-action]:focus-visible]:outline-offset-2 has-[[data-chip-action]:focus-visible]:outline-secondary',
+ ])
+ ->merge(array_filter(['style' => $anchor ? "anchor-name: {$anchor}" : null]));
+
+ $action = $attributes->except($outer)
+ ->class([
+ 'flex h-full min-w-0 items-center outline-none',
+ match (true) {
+ filled($avatar) => 'ps-0.75',
+ filled($icon) => 'ps-1.75',
+ default => 'ps-2.75',
+ },
+ $removable ? 'pe-2' : 'pe-2.75',
+ 'cursor-pointer before:absolute before:-inset-px before:-z-10 before:rounded-corner-sm before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => $interactive && ! $disabled,
+ $target => $interactive,
+ 'cursor-not-allowed' => $interactive && $disabled,
+ 'pointer-events-none' => $inert,
+ ])
+ ->merge(array_filter([
+ 'data-chip-action' => $interactive ? true : null,
+ 'href' => $isLink ? $link : null,
+ 'target' => $isLink && $external ? '_blank' : null,
+ 'rel' => $isLink && $external ? 'noopener' : null,
+ 'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
+ 'aria-disabled' => $inert ? 'true' : null,
+ 'tabindex' => $inert ? '-1' : null,
+ 'type' => $actionTag === 'button' ? 'button' : null,
+ 'disabled' => $actionTag === 'button' && $disabled ? true : null,
+ 'aria-pressed' => $actionTag === 'button' && $selected !== null ? ($selected ? 'true' : 'false') : null,
+ ], fn ($attribute): bool => $attribute !== null));
+ } else {
+ $tag = match (true) {
+ $checkbox => 'label',
+ $isLink => 'a',
+ default => 'button',
+ };
+ $control = $checkbox
+ ? $attributes->except($outer)->merge(array_filter([
+ 'type' => 'checkbox',
+ 'name' => $name,
+ 'value' => $value,
+ 'checked' => $checked ? true : null,
+ 'disabled' => $disabled ? true : null,
+ ], fn ($attribute): bool => $attribute !== null))->class('peer sr-only')
+ : null;
+
+ $container = ($checkbox ? $attributes->only($outer) : $attributes)
+ ->class([
+ $base,
+ 'state-layer relative',
+ 'focus-ring' => ! $checkbox,
+ 'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary has-focus-visible:before:opacity-10' => $checkbox,
+ $kind === 'filter' || filled($icon) ? 'ps-1.75' : 'ps-3.75',
+ $iconRight ? 'pe-1.75' : 'pe-3.75',
+ $colours,
+ $target,
+ 'cursor-pointer' => ! $disabled,
+ 'cursor-not-allowed' => $disabled && ! $isLink,
+ 'pointer-events-none' => $inert,
+ ])
+ ->merge(array_filter([
+ 'data-chip' => $kind,
+ 'href' => $isLink ? $link : null,
+ 'target' => $isLink && $external ? '_blank' : null,
+ 'rel' => $isLink && $external ? 'noopener' : null,
+ 'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
+ 'aria-disabled' => $inert ? 'true' : null,
+ 'tabindex' => $inert ? '-1' : null,
+ 'type' => $tag === 'button' ? 'button' : null,
+ 'disabled' => $tag === 'button' && $disabled ? true : null,
+ 'name' => $tag === 'button' ? $name : null,
+ 'value' => $tag === 'button' ? $value : null,
+ 'aria-pressed' => $kind === 'filter' && ! $checkbox ? ($selected ? 'true' : 'false') : null,
+ 'style' => $anchor ? "anchor-name: {$anchor}" : null,
+ ], fn ($attribute): bool => $attribute !== null));
+ }
+@endphp
+
+@if ($kind === 'input')
+
+ <{{ $actionTag }} {{ $action }}>
+ @if ($avatar)
+ @if ($initials)
+ $disabled])>{{ $avatar }}
+ @else
+ $disabled]) />
+ @endif
+ @elseif ($icon)
+
+ @endif
+
+ {{ $label ?? $slot }}
+ {{ $actionTag }}>
+
+ @if ($removable)
+ ! $disabled,
+ 'before:absolute before:-inset-0.75 before:rounded-corner-full before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => ! $disabled,
+ 'after:absolute after:-inset-x-2 after:-inset-y-3.75',
+ 'cursor-not-allowed' => $disabled,
+ ])
+ >
+
+
+ @endif
+
+ @if (filled($name))
+
+ @endif
+
+ @if ($tooltip !== null)
+
+ @endif
+
+@else
+ <{{ $tag }} {{ $container }}>
+ @if ($checkbox)
+
+ @endif
+
+ @if ($kind === 'filter')
+ {{-- The check grows in from nothing; beside an icon it takes the icon's place. --}}
+ filled($icon),
+ 'h-4.5 w-0 transition-[width] duration-(--md-sys-motion-effects-default-duration) ease-effects-default group-has-checked/chip:w-4.5 group-has-checked/chip:duration-(--md-sys-motion-spatial-fast-duration) group-has-checked/chip:ease-spatial-fast group-aria-pressed/chip:w-4.5 group-aria-pressed/chip:duration-(--md-sys-motion-spatial-fast-duration) group-aria-pressed/chip:ease-spatial-fast' => blank($icon),
+ ])>
+ @if ($icon)
+
+ @endif
+
+
+ @elseif ($icon)
+
+ @endif
+
+ {{ $label ?? $slot }}
+
+ @if ($iconRight)
+
+ @endif
+
+ @if ($tooltip !== null)
+
+ @endif
+ {{ $tag }}>
+@endif
diff --git a/resources/views/components/choices.blade.php b/resources/views/components/choices.blade.php
new file mode 100644
index 00000000..293f9086
--- /dev/null
+++ b/resources/views/components/choices.blade.php
@@ -0,0 +1,218 @@
+{{-- Choosing from a list: M3's filter chips, or a searchable field with a menu.
+
+ Two shapes for two jobs, both on `options` (`['id' => …, 'name' => …]`, read through
+ `option-value` and `option-label`), `label`, `hint` and `single`:
+
+ - **Chips**, the default. Every option is on screen and a press turns it on or off — "the days
+ you are free" is seven filter chips, not a dropdown opened seven times. `single` makes them
+ choice chips, one on at a time.
+ - **`searchable`**, for a list too long to lay out — four hundred time zones. A text field
+ that filters as you type, with the matches in M3's menu under it: the arrow keys move,
+ Enter chooses, Escape puts the field back. One value only. The list is a popover in the top
+ layer, placed by CSS anchor positioning, so a card's clipping never cuts it off. `icon`,
+ `variant` and `placeholder` are the field's.
+
+ The selection lives in Alpine, entangled with the `wire:model` property (live with
+ `wire:model.live`), rather than in native checkboxes, which would send every value back as a
+ string: a list of integers stays a list of integers. Without `wire:model` it works on its own
+ and binds with `x-model`. Errors for the property and its items replace the hint. When the
+ options change on the server, give the component a `wire:key` that changes with them: the
+ options are baked into its Alpine state. --}}
+
+@props([
+ 'label' => null,
+ 'hint' => null,
+ 'icon' => null,
+ 'options' => [],
+ 'optionValue' => 'id',
+ 'optionLabel' => 'name',
+ 'single' => false,
+ 'searchable' => false,
+ 'variant' => null,
+ 'placeholder' => null,
+ 'value' => null,
+])
+
+@php
+ $model = $attributes->wire('model')->value() ?: null;
+ $messages = $model !== null && isset($errors)
+ ? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
+ : [];
+ $choices = collect($options)->map(fn ($option): array => [
+ 'value' => data_get($option, $optionValue),
+ 'label' => (string) data_get($option, $optionLabel),
+ 'disabled' => (bool) data_get($option, 'disabled', false),
+ ])->values()->all();
+ $single = $single || $searchable;
+
+ // Rendered as the bound property already says, so nothing flips once Alpine starts.
+ $current = $value;
+ if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
+ $current = data_get($component, $model);
+ }
+ $isSelected = fn ($candidate): bool => $single
+ ? $current !== null && (string) $current === (string) $candidate
+ : in_array((string) $candidate, array_map('strval', array_filter((array) $current, 'is_scalar')), true);
+
+ $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|choices'), 0, 12);
+ $anchor = '--material-choices-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
+@endphp
+
+@if ($searchable)
+ only(['class', 'wire:key', 'x-model']) }}
+ x-data="{
+ @if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
+ options: @js($choices),
+ query: '',
+ open: false,
+ active: 0,
+ selecting: false,
+ get selectedLabel() {
+ return this.options.find((option) => option.value === this.value)?.label ?? '';
+ },
+ get filtered() {
+ const query = this.query.trim().toLowerCase();
+
+ return query === '' || this.query === this.selectedLabel
+ ? this.options
+ : this.options.filter((option) => option.label.toLowerCase().includes(query));
+ },
+ init() {
+ this.query = this.selectedLabel;
+ this.$watch('value', () => { if (! this.open) this.query = this.selectedLabel; });
+ this.$watch('open', (open) => {
+ const list = this.$refs.list;
+ if (open && ! list.matches(':popover-open')) list.showPopover();
+ if (! open && list.matches(':popover-open')) list.hidePopover();
+ });
+ },
+ show() {
+ this.open = true;
+ this.active = Math.max(0, this.filtered.findIndex((option) => option.value === this.value));
+ this.$nextTick(() => this.reveal());
+ },
+ close() {
+ this.open = false;
+ this.query = this.selectedLabel;
+ },
+ move(step) {
+ if (! this.open) return this.show();
+ if (this.filtered.length === 0) return;
+ this.active = (this.active + step + this.filtered.length) % this.filtered.length;
+ this.$nextTick(() => this.reveal());
+ },
+ choose(option) {
+ if (! option || option.disabled) return;
+ this.value = option.value;
+ this.query = option.label;
+ this.open = false;
+ },
+ reveal() {
+ this.$refs.list.querySelector('[data-active]')?.scrollIntoView({ block: 'nearest' });
+ },
+ }"
+ @if ($model === null) x-modelable="value" @endif
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+@else
+ only(['class', 'wire:key', 'x-model']) }}
+ x-data="{
+ @if ($model !== null) selection: @entangle($attributes->wire('model')), @else selection: @js($current ?? ($single ? null : [])), @endif
+ options: @js(array_column($choices, 'value')),
+ selected(index) {
+ const value = this.options[index];
+
+ return @js($single) ? this.selection === value : (this.selection ?? []).includes(value);
+ },
+ toggle(index) {
+ const value = this.options[index];
+
+ if (@js($single)) {
+ this.selection = value;
+
+ return;
+ }
+
+ const current = this.selection ?? [];
+ this.selection = current.includes(value) ? current.filter((each) => each !== value) : [...current, value];
+ },
+ }"
+ @if ($model === null) x-modelable="selection" @endif
+ >
+
+ @foreach ($choices as $index => $choice)
+
+ @endforeach
+
+
+@endif
diff --git a/resources/views/components/search.blade.php b/resources/views/components/search.blade.php
new file mode 100644
index 00000000..0dd13fa5
--- /dev/null
+++ b/resources/views/components/search.blade.php
@@ -0,0 +1,91 @@
+{{-- M3's search: a search bar that opens into a search view with the results.
+
+ Bind the input like any other (`wire:model.live.debounce.300ms="query"`) and render the
+ results in the slot, from a Livewire property or computed property that follows the query;
+ `empty` is shown instead when the slot renders nothing (say, "No shares match"). Results are
+ usually ``s with a `link`, or buttons: choosing one closes the view.
+
+ Docked under the bar from `sm`, full screen below it with a back arrow (resources/css/
+ components/search.css); `docked` keeps it docked at every width. `placeholder` ("Search"),
+ `label` (the input's name when it differs from the placeholder), leading `icon` (`search`), and
+ a `trailing` slot for an avatar or icon buttons in the bar. Every other attribute reaches the
+ ` `. --}}
+
+@props([
+ 'placeholder' => null,
+ 'label' => null,
+ 'icon' => 'search',
+ 'docked' => false,
+])
+
+@php
+ $model = $attributes->wire('model')->value() ?: null;
+ $placeholder ??= __('Search');
+ $id = $attributes->get('id') ?? 'material-search-'.substr(md5($model.'|'.$placeholder), 0, 10);
+@endphp
+
+only(['class', 'wire:key'])->class(['relative']) }}
+>
+
+
+
+
+
+
+
+
+
+ except(['class', 'wire:key', 'id', 'placeholder', 'type']) }}
+ x-ref="input"
+ id="{{ $id }}"
+ type="search"
+ autocomplete="off"
+ enterkeyhint="search"
+ placeholder="{{ $placeholder }}"
+ aria-label="{{ $label ?? $placeholder }}"
+ aria-controls="{{ $id }}-view"
+ aria-expanded="false"
+ x-bind:aria-expanded="open.toString()"
+ x-on:focus="focused()"
+ x-on:click="show()"
+ x-on:input="show()"
+ x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
+ data-search-input
+ />
+
+
+
+
+
+ @isset($trailing)
+ {{ $trailing }}
+ @endisset
+
+
+
+ @if ($slot->hasActualContent())
+
{{ $slot }}
+ @elseif (isset($empty))
+
+ @endif
+
+
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php
index a5208c45..c3e18434 100644
--- a/resources/views/showcase/index.blade.php
+++ b/resources/views/showcase/index.blade.php
@@ -19,5 +19,6 @@
@include('livewire-material::showcase.sections.containment')
@include('livewire-material::showcase.sections.carousel')
@include('livewire-material::showcase.sections.fields')
+ @include('livewire-material::showcase.sections.chips')
@endsection
diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php
index d5477b6b..28f67f42 100644
--- a/resources/views/showcase/layout.blade.php
+++ b/resources/views/showcase/layout.blade.php
@@ -18,7 +18,7 @@
Livewire Material
- @foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields'] as $anchor => $section)
+ @foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips'] as $anchor => $section)
{{ $section }}
@endforeach
diff --git a/resources/views/showcase/sections/chips.blade.php b/resources/views/showcase/sections/chips.blade.php
new file mode 100644
index 00000000..42de4e12
--- /dev/null
+++ b/resources/views/showcase/sections/chips.blade.php
@@ -0,0 +1,79 @@
+@php
+ $examples = [
+ 'Assist chips' => <<<'BLADE'
+
+
+
+
+
+
+
+
+ BLADE,
+ 'Filter chips' => <<<'BLADE'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BLADE,
+ 'Input chips' => <<<'BLADE'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BLADE,
+ 'Suggestion chips' => <<<'BLADE'
+
+
+
+
+
+
+ BLADE,
+ 'A set that scrolls' => <<<'BLADE'
+
+
+
+
+
+
+
+
+
+
+ BLADE,
+ ];
+@endphp
+
+
diff --git a/resources/views/showcase/sections/fields.blade.php b/resources/views/showcase/sections/fields.blade.php
index 7a4defd9..56a6ad39 100644
--- a/resources/views/showcase/sections/fields.blade.php
+++ b/resources/views/showcase/sections/fields.blade.php
@@ -98,6 +98,39 @@
BLADE,
+ 'Choices' => <<<'BLADE'
+
+ BLADE,
+ 'Search' => <<<'BLADE'
+
+
+
+
+
+
+
+
+
+ AR
+
+
+
+
+
+ Type to search your settings.
+
+
+ BLADE,
'A form' => <<<'BLADE'
@@ -121,7 +154,7 @@
<x-input>, <x-password>, <x-textarea>, <x-select>, <x-file>
- (all on <x-field>, outlined or filled), <x-checkbox>, <x-radio>, <x-toggle> and <x-form>.
+ (all on <x-field>, outlined or filled), <x-checkbox>, <x-radio>, <x-toggle>, <x-choices>, <x-search> and <x-form>.
@foreach ($examples as $title => $code)
diff --git a/tests/Browser/ChipsTest.php b/tests/Browser/ChipsTest.php
new file mode 100644
index 00000000..dd1613d2
--- /dev/null
+++ b/tests/Browser/ChipsTest.php
@@ -0,0 +1,174 @@
+ */
+ public array $kinds = ['photos'];
+
+ /** @var list */
+ public array $tags = ['php', 'js', 'css'];
+
+ public function onlyVideo(): void
+ {
+ $this->kinds = ['video'];
+ }
+
+ public function removeTag(string $tag): void
+ {
+ $this->tags = array_values(array_diff($this->tags, [$tag]));
+ }
+
+ public function render(): string
+ {
+ return <<<'BLADE'
+
+
kinds: {{ implode(',', $kinds) }}
+
+
+ @foreach (['photos' => 'Photos', 'documents' => 'Documents', 'video' => 'Video'] as $value => $name)
+
+ @endforeach
+
+
+
+
+
tags: {{ implode(',', $tags) }}
+
+
+ @foreach ($tags as $tag)
+
+ @endforeach
+
+
+ BLADE;
+ }
+}
+
+function chipProbe()
+{
+ Livewire::component('chip-probe', ChipProbe::class);
+
+ Route::middleware('web')->get('/chip-probe', fn () => Blade::render(<<<'BLADE'
+
+
+
+
+ @vite(config('livewire-material.showcase.vite'))
+ @livewireStyles
+
+
+
+ @livewireScripts
+
+
+ BLADE));
+
+ return visit('/chip-probe')->waitForEvent('networkidle')
+ ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
+}
+
+function chipShowcase()
+{
+ return visit('/material')->waitForEvent('networkidle')
+ ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
+}
+
+/**
+ * The script for a showcase filter chip's checkbox, found by its value.
+ */
+function filterInput(string $value): string
+{
+ return "document.querySelector('#chips input[type=\"checkbox\"][value=\"{$value}\"]')";
+}
+
+it('toggles a filter chip with a click and with Space, and grows its check in', function () {
+ $check = fn (string $value): string => filterInput($value).".parentElement.querySelector('[data-chip-check]').parentElement.getBoundingClientRect().width";
+
+ $page = chipShowcase()->assertNoJavaScriptErrors();
+
+ $page->click('#chips label:has-text("Documents")')
+ ->assertScript(filterInput('documents').'.checked')
+ ->assertScript("Math.round({$check('documents')}) === 18")
+ ->assertScript('getComputedStyle('.filterInput('documents').".parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
+ ->assertScript("window.eval(\"Alpine.\$data(document.querySelector('#chips input[value=documents]')).kinds.join(',')\") === 'photos,documents'");
+
+ // A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
+ // because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
+ $page->keys('body', 'Tab');
+ $page->script(filterInput('archives').'.focus()');
+ $page->keys(':focus', 'Space')
+ ->assertScript(filterInput('archives').'.checked')
+ ->assertScript("Math.round({$check('archives')}) === 18");
+
+ $page->keys(':focus', 'Space')
+ ->assertScript('! '.filterInput('archives').'.checked')
+ ->assertScript("Math.round({$check('archives')}) === 0");
+});
+
+it('binds filter chips to a Livewire array, and a server change reaches them after a morph', function () {
+ $input = fn (string $value): string => "document.querySelector('input[value=\"{$value}\"]')";
+
+ $page = chipProbe()->assertNoJavaScriptErrors();
+
+ $page->assertScript("{$input('photos')}.checked && ! {$input('documents')}.checked");
+
+ $page->click('label:has-text("Documents")')
+ ->assertSeeIn('#kinds', 'photos,documents');
+
+ $page->click('button:has-text("Only video")')
+ ->assertSeeIn('#kinds', 'video')
+ ->assertScript("! {$input('photos')}.checked && ! {$input('documents')}.checked && {$input('video')}.checked")
+ ->assertScript("getComputedStyle({$input('video')}.parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
+ ->assertScript("getComputedStyle({$input('photos')}.parentElement).backgroundColor === 'rgba(0, 0, 0, 0)'");
+});
+
+it('removes a Livewire input chip with its button and with Delete, focusing the chip after it', function () {
+ $page = chipProbe();
+
+ $page->click('button[aria-label="Remove js"]')
+ ->assertSeeIn('#tags', 'php,css')
+ ->assertScript("document.querySelector('button[aria-label=\"Remove js\"]') === null");
+
+ $page->script("document.querySelector('button[aria-label=\"Remove php\"]').focus()");
+
+ $page->keys(':focus', 'Delete')
+ ->assertScript("document.querySelector('#tags').textContent === 'css'")
+ ->assertScript("document.querySelector('button[aria-label=\"Remove php\"]') === null")
+ ->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove css'");
+});
+
+it('removes an Alpine input chip with Backspace, focusing the chip before it', function () {
+ $remove = fn (string $who): string => "document.querySelector('#chips button[aria-label=\"Remove {$who}@example.com\"]')";
+
+ $page = chipShowcase();
+
+ // Named in the browser: the chips come from x-for, so the server never knew their labels.
+ $page->assertScript("{$remove('ben')} !== null");
+ $page->script("{$remove('ben')}.focus()");
+
+ $page->keys(':focus', 'Backspace')
+ ->assertScript("{$remove('ben')} === null")
+ ->assertScript("{$remove('anna')} !== null && {$remove('chiara')} !== null")
+ ->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove anna@example.com'");
+});
+
+it('scrolls a chip set sideways, fading the edge it can still scroll towards', function () {
+ $row = "document.querySelector('#chips [data-chip-set][x-data]')";
+
+ $page = chipShowcase();
+
+ $page->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
+ ->assertScript("{$row}.hasAttribute('data-scroll-end') && ! {$row}.hasAttribute('data-scroll-start')")
+ ->assertScript("getComputedStyle({$row}).flexWrap === 'nowrap'");
+
+ $page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()");
+
+ $page->assertScript("Math.abs({$row}.scrollLeft) > 0")
+ ->assertScript("{$row}.hasAttribute('data-scroll-start')")
+ ->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '1.5rem'");
+});
diff --git a/tests/Browser/PickingTest.php b/tests/Browser/PickingTest.php
new file mode 100644
index 00000000..bfd3abff
--- /dev/null
+++ b/tests/Browser/PickingTest.php
@@ -0,0 +1,168 @@
+ */
+ public array $days = [2];
+
+ public ?string $zone = 'Europe/Zurich';
+
+ public string $query = '';
+
+ public function render(): string
+ {
+ return <<<'BLADE'
+
+
days: {{ json_encode($days) }}
+
zone: {{ $zone }}
+
query: {{ $query }}
+
+
+
+
+
+
+
+
+ @foreach (array_filter(['holiday.zip', 'contract.pdf', 'review.mp4'], fn ($file) => $query === '' || str_contains($file, $query)) as $file)
+ {{ $file }}
+ @endforeach
+ No files match.
+
+
+
+ BLADE;
+ }
+}
+
+function pickProbe()
+{
+ Livewire::component('pick-probe', PickProbe::class);
+
+ Route::middleware('web')->get('/pick-probe', fn () => Blade::render(<<<'BLADE'
+
+
+
+
+ @vite(config('livewire-material.showcase.vite'))
+ @livewireStyles
+
+
+
+ @livewireScripts
+
+
+ BLADE));
+
+ return visit('/pick-probe')->waitForEvent('networkidle')
+ ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
+}
+
+it('keeps the values of filter chips as the property\'s own type', function () {
+ pickProbe()
+ ->assertAttribute('button[aria-pressed="true"]', 'aria-pressed', 'true')
+ ->click('button:has-text("Mon")')
+ ->assertSeeIn('#days', '[2,1]')
+ ->click('button:has-text("Tue")')
+ ->assertSeeIn('#days', '[1]')
+ ->assertScript("[...document.querySelectorAll('[aria-pressed=\"true\"]')].map((chip) => chip.textContent.trim()).join() === 'Mon'");
+});
+
+it('filters a searchable choice as it is typed and chooses from the keyboard', function () {
+ $list = "document.querySelector('#zone-field-list')";
+
+ $page = pickProbe()
+ ->assertValue('#zone-field', 'Zurich')
+ ->click('#zone-field')
+ ->assertScript("{$list}.matches(':popover-open')")
+ ->assertAttribute('#zone-field', 'aria-expanded', 'true');
+
+ $page->type('#zone-field', 'ber')
+ ->assertScript("{$list}.querySelectorAll('[role=\"option\"]').length === 1");
+
+ $page->keys('#zone-field', 'Enter')
+ ->assertSeeIn('#zone', 'Europe/Berlin')
+ ->assertValue('#zone-field', 'Berlin')
+ ->assertScript("! {$list}.matches(':popover-open')");
+});
+
+it('puts a searchable choice back on Escape and never chooses a disabled option', function () {
+ $page = pickProbe()->click('#zone-field')->type('#zone-field', 'ber');
+
+ $page->keys('#zone-field', 'Escape')
+ ->assertValue('#zone-field', 'Zurich')
+ ->assertSeeIn('#zone', 'Europe/Zurich');
+
+ $page->click('#zone-field')->type('#zone-field', 'UTC')->keys('#zone-field', 'Enter')
+ ->assertSeeIn('#zone', 'Europe/Zurich');
+});
+
+it('opens a searchable choice\'s list above a container that clips', function () {
+ pickProbe()
+ ->click('#zone-field')
+ ->assertScript(<<<'JS'
+ (() => {
+ const list = document.querySelector('#zone-field-list');
+ const clip = document.querySelector('#clip').getBoundingClientRect();
+ const box = list.getBoundingClientRect();
+ const probe = document.elementFromPoint(box.left + box.width / 2, box.bottom - 8);
+
+ return box.bottom > clip.bottom && list.contains(probe);
+ })()
+ JS);
+});
+
+it('opens the search view with the results Livewire renders for the query', function () {
+ $view = "document.querySelector('#find-view')";
+
+ $page = pickProbe()
+ ->click('#find')
+ ->assertScript("getComputedStyle({$view}).display !== 'none'")
+ ->assertAttribute('#find', 'aria-expanded', 'true');
+
+ $page->type('#find', 'con')
+ ->assertSeeIn('#query', 'con')
+ ->assertScript("[...{$view}.querySelectorAll('button')].map((button) => button.textContent.trim()).join() === 'contract.pdf'");
+
+ $page->keys('#find', 'ArrowDown')
+ ->assertScript("document.activeElement.textContent.trim() === 'contract.pdf'");
+
+ $page->keys(':focus', 'Escape')
+ ->assertScript("getComputedStyle({$view}).display === 'none'")
+ ->assertScript("document.activeElement.id === 'find'")
+ ->assertAttribute('#find', 'aria-expanded', 'false');
+});
+
+it('says so when nothing matches, and closes on a press outside or a chosen result', function () {
+ $view = "document.querySelector('#find-view')";
+
+ $page = pickProbe()
+ ->click('#find')
+ ->type('#find', 'zzz')
+ ->assertSeeIn('#find-view', 'No files match.');
+
+ // The open view covers what is under it, so the press lands above it.
+ $page->click('#days')
+ ->assertScript("getComputedStyle({$view}).display === 'none'");
+
+ $page->click('#find')
+ ->clear('#find')
+ ->assertScript("{$view}.querySelectorAll('button').length === 3")
+ ->click('#find-view button:has-text("review.mp4")')
+ ->assertScript("getComputedStyle({$view}).display === 'none'");
+});
+
+it('takes the whole screen on a compact window, with a back arrow', function () {
+ $page = pickProbe()->resize(400, 800);
+
+ $page->click('#find')
+ ->assertScript("document.querySelector('[data-search]').hasAttribute('data-full-screen')")
+ ->assertScript("(() => { const box = document.querySelector('#find-view').getBoundingClientRect(); return box.top === 0 && box.width === 400; })()")
+ ->click('[data-search-back]')
+ ->assertScript("! document.querySelector('[data-search]').hasAttribute('data-open')");
+});
diff --git a/tests/Feature/Components/ChipTest.php b/tests/Feature/Components/ChipTest.php
new file mode 100644
index 00000000..02bce831
--- /dev/null
+++ b/tests/Feature/Components/ChipTest.php
@@ -0,0 +1,272 @@
+]*>/s', (string) test()->blade($blade), $matches);
+
+ return $matches[0] ?? '';
+}
+
+it('is an outlined assist chip, a button, by default', function () {
+ $html = (string) $this->blade(' ');
+
+ expect(chipTag(' ', 'button'))
+ ->toContain('data-chip="assist"')
+ ->toContain('type="button"')
+ ->toContain('h-8')
+ ->toContain('rounded-corner-sm')
+ ->toContain('type-label-lg')
+ ->toContain('border-outline-variant text-on-surface')
+ ->toContain('ps-1.75 pe-3.75')
+ ->and($html)
+ ->toContain('me-2 size-4.5 text-primary')
+ ->toContain('ms-2 size-4.5 text-primary')
+ ->toContain('Add to calendar');
+});
+
+it('pads a chip without icons by 16px on both sides', function () {
+ expect(chipTag(' ', 'button'))->toContain('ps-3.75 pe-3.75');
+});
+
+it('falls back to an assist chip for a type it does not know', function () {
+ expect(chipTag(' ', 'button'))->toContain('data-chip="assist"');
+});
+
+it('draws an elevated chip on surface-container-low at elevation 1', function () {
+ expect(chipTag(' ', 'button'))
+ ->toContain('border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2')
+ ->not->toContain('border-outline-variant');
+});
+
+it('greys a disabled chip, flat or elevated', function () {
+ expect(chipTag(' ', 'button'))
+ ->toContain('disabled')
+ ->toContain('border-on-surface/12 text-on-surface/38')
+ ->and(chipTag(' ', 'button'))
+ ->toContain('bg-on-surface/12 text-on-surface/38')
+ ->and((string) $this->blade(' '))
+ ->not->toContain('text-primary');
+});
+
+it('links with wire:navigate, and a disabled link leaves the tab order', function () {
+ expect(chipTag(' ', 'a'))
+ ->toContain('href="/directions"')
+ ->toContain('wire:navigate')
+ ->not->toContain('type=')
+ ->and(chipTag(' ', 'a'))
+ ->toContain('target="_blank"')
+ ->toContain('rel="noopener"')
+ ->not->toContain('wire:navigate')
+ ->and(chipTag(' ', 'a'))
+ ->toContain('aria-disabled="true"')
+ ->toContain('tabindex="-1"')
+ ->toContain('pointer-events-none');
+});
+
+it('draws a suggestion chip in on-surface-variant', function () {
+ expect(chipTag(' ', 'button'))
+ ->toContain('data-chip="suggestion"')
+ ->toContain('border-outline-variant text-on-surface-variant')
+ ->and(chipTag(' ', 'button'))
+ ->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1');
+});
+
+it('attaches a plain tooltip', function () {
+ $html = (string) $this->blade(' ');
+
+ expect($html)
+ ->toContain('popover="manual"')
+ ->toContain('Share this file')
+ ->toMatch('/]*style="anchor-name: --material-chip-[a-z0-9]{10}"/');
+});
+
+it('makes a filter chip without a model a toggle button the caller owns', function () {
+ $html = (string) $this->blade(' ');
+
+ expect(chipTag(' ', 'button'))
+ ->toContain('data-chip="filter"')
+ ->toContain('aria-pressed="true"')
+ ->toContain('wire:click="toggleStarred"')
+ ->toContain('aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container')
+ ->and(chipTag(' ', 'button'))
+ ->toContain('aria-pressed="false"')
+ ->and($html)
+ ->not->toContain('type="checkbox"')
+ ->toContain('group-aria-pressed/chip:w-4.5')
+ ->toContain('data-chip-check');
+});
+
+it('makes a bound filter chip a native checkbox under the chip', function () {
+ $html = (string) $this->blade(' ');
+
+ expect(chipTag(' ', 'label'))
+ ->toContain('data-chip="filter"')
+ ->toContain('me-4')
+ ->toContain('wire:key="photos"')
+ ->toContain('has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container')
+ ->toContain('has-focus-visible:outline-3')
+ ->not->toContain('x-model')
+ ->and(chipTag(' ', 'input'))
+ ->toContain('type="checkbox"')
+ ->toContain('value="photos"')
+ ->toContain('x-model="kinds"')
+ ->toContain('sr-only')
+ ->not->toContain('me-4')
+ ->not->toContain('checked')
+ ->and($html)
+ ->toContain('group-has-checked/chip:w-4.5');
+});
+
+it('submits a named filter chip, checked when selected, and greys a disabled one', function () {
+ expect(chipTag(' ', 'input'))
+ ->toContain('name="starred"')
+ ->toContain('checked')
+ ->and(chipTag(' ', 'input'))
+ ->toContain('disabled')
+ ->and(chipTag(' ', 'label'))
+ ->toContain('border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12')
+ ->toContain('cursor-not-allowed');
+});
+
+it('swaps a filter chip\'s icon for the check when selected', function () {
+ $html = (string) $this->blade(' ');
+
+ expect($html)
+ ->toContain('group-has-checked/chip:opacity-0')
+ ->toContain('text-primary')
+ ->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1')
+ ->not->toContain('w-0')
+ ->and(substr_count($html, 'toBe(2);
+});
+
+it('draws an input chip with an avatar, an icon or neither', function () {
+ expect(chipTag(' ', 'span'))
+ ->toContain('data-chip="input"')
+ ->toContain('border-outline-variant text-on-surface-variant')
+ ->not->toContain('x-data')
+ ->and((string) $this->blade(' '))
+ ->toContain('ps-0.75 pe-2.75')
+ ->toContain('size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container')
+ ->toContain('>AM')
+ ->not->toContain('and((string) $this->blade(' '))
+ ->toContain(' and((string) $this->blade(' '))
+ ->toContain('ps-1.75 pe-2.75');
+});
+
+it('makes an input chip a button when it has its own action, and selects it', function () {
+ $html = (string) $this->blade(' ');
+
+ expect(chipTag(' ', 'button'))
+ ->toContain('data-chip-action')
+ ->toContain('aria-pressed="true"')
+ ->toContain('wire:click="open(1)"')
+ ->and(chipTag(' ', 'span'))
+ ->toContain('border-transparent bg-secondary-container text-on-secondary-container')
+ ->and($html)
+ ->toContain('me-2 size-4.5 text-primary');
+});
+
+it('gives a removable input chip a named remove button that calls wire:remove', function () {
+ $html = (string) $this->blade(' ');
+
+ expect(chipTag(' ', 'span'))
+ ->toContain('x-data="materialChip"')
+ ->toContain('x-on:keydown="removeOnKey($event)"')
+ ->not->toContain('wire:remove')
+ ->and($html)
+ ->toContain('data-chip-remove aria-label="Remove anna@example.com"')
+ ->toContain('wire:click="removeRecipient(3)"')
+ ->toContain('x-on:click="removeChip($event)"')
+ ->toContain(' ')
+ ->toContain('ps-2.75 pe-2');
+});
+
+it('runs an Alpine remove expression, or takes the chip off the page with neither', function () {
+ expect((string) $this->blade(' '))
+ ->toContain('x-on:click="removeChip($event); tags.splice(0, 1)"')
+ ->not->toContain('wire:click')
+ ->and((string) $this->blade(' '))
+ ->toContain('x-on:click="removeChip($event); $root.remove()"');
+});
+
+it('leaves a client-rendered chip\'s remove button to be named in the browser', function () {
+ expect((string) $this->blade(' '))
+ ->toContain('data-chip-remove="Remove :label"')
+ ->not->toContain('aria-label=');
+});
+
+it('disables a removable input chip and its remove button', function () {
+ $html = (string) $this->blade(' ');
+
+ expect($html)
+ ->toContain('border-on-surface/12 text-on-surface/38')
+ ->toMatch('/]*data-chip-remove[^>]*disabled/s');
+});
+
+it('groups chips in a wrapping row named by its label, with a hint', function () {
+ $html = (string) $this->blade(' ');
+
+ preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
+ preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy);
+
+ expect($html)
+ ->toContain('role="group"')
+ ->toContain('data-chip-set class="flex flex-wrap gap-2"')
+ ->toContain("id=\"{$labelledBy[1]}\" class=\"mb-2 type-label-lg text-on-surface-variant\">File types
")
+ ->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these")
+ ->not->toContain('materialChipSet');
+});
+
+it('scrolls a chip set on one line with fading edges', function () {
+ expect((string) $this->blade(' '))
+ ->toContain('aria-label="Sort"')
+ ->toContain('x-data="materialChipSet"')
+ ->toContain('wire:ignore.self')
+ ->toContain('overflow-x-auto')
+ ->toContain('data-scroll-start:[--chip-fade-start:1.5rem]')
+ ->not->toContain('flex-wrap');
+});
+
+it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {
+ $component = new class extends Component
+ {
+ /** @var list */
+ public array $kinds = ['photos'];
+
+ public function save(): void
+ {
+ $this->validate(['kinds' => 'max:1'], ['kinds.max' => 'Pick one kind.']);
+ }
+
+ public function render(): string
+ {
+ return <<<'BLADE'
+
+
+
+
+
+
+ BLADE;
+ }
+ };
+
+ Livewire::test($component)
+ ->assertSeeHtml('wire:model.live="kinds"')
+ ->assertSeeHtml('value="photos" checked')
+ ->assertDontSeeHtml('value="documents" checked')
+ ->assertSee('Show only these')
+ ->set('kinds', ['photos', 'documents'])
+ ->assertSeeHtml('value="documents" checked')
+ ->call('save')
+ ->assertSee('Pick one kind.')
+ ->assertDontSee('Show only these');
+});
diff --git a/tests/Feature/Components/ChoicesTest.php b/tests/Feature/Components/ChoicesTest.php
new file mode 100644
index 00000000..9447b5c7
--- /dev/null
+++ b/tests/Feature/Components/ChoicesTest.php
@@ -0,0 +1,91 @@
+blade(' ');
+
+ expect($html)
+ ->toContain('role="group"')
+ ->toContain('Days')
+ ->toContain('Pick any')
+ ->toContain('x-modelable="selection"')
+ ->toContain('x-on:click="toggle(0)"')
+ ->toContain('x-bind:aria-pressed="selected(2).toString()"')
+ ->and(substr_count($html, 'aria-pressed="true"'))->toBe(1)
+ ->and(substr_count($html, 'aria-pressed="false"'))->toBe(2)
+ ->and($html)->toContain('disabled');
+});
+
+it('entangles the selection with its Livewire property and renders it as the property says', function () {
+ Livewire::component('choices-probe', new class extends Component
+ {
+ /** @var list */
+ public array $days = [1, 3];
+
+ public function render(): string
+ {
+ return '
';
+ }
+ });
+
+ $html = Livewire::test('choices-probe')->html();
+
+ expect($html)
+ ->toContain(".entangle('days').live")
+ ->not->toContain('x-modelable')
+ ->and(substr_count($html, 'aria-pressed="true"'))->toBe(2);
+});
+
+it('draws a searchable combobox with a popover list', function () {
+ $html = (string) $this->blade(' ');
+
+ expect($html)
+ ->toContain('role="combobox"')
+ ->toContain('aria-controls="zone-list"')
+ ->toContain('aria-expanded="false"')
+ ->toContain('toContain('id="zone-list"')
+ ->toContain('role="listbox"')
+ ->toContain('popover="manual"')
+ ->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
+ ->toContain('x-modelable="value"')
+ ->toContain('field-arrow')
+ ->toContain('Nothing matches');
+
+ preg_match('/anchor-name: (--material-choices-[a-z0-9]{10})/', $html, $anchor);
+
+ expect($html)->toContain("position-anchor: {$anchor[1]}");
+});
+
+it('shows the errors for the property and its items', function () {
+ Livewire::component('choices-errors', new class extends Component
+ {
+ /** @var list */
+ public array $days = [];
+
+ public ?string $zone = null;
+
+ public function mount(): void
+ {
+ $this->addError('days.0', 'Mon is not a working day.');
+ $this->addError('zone', 'Choose a time zone.');
+ }
+
+ public function render(): string
+ {
+ return <<<'BLADE'
+
+
+
+
+ BLADE;
+ }
+ });
+
+ expect(Livewire::test('choices-errors')->html())
+ ->toContain('Mon is not a working day.')
+ ->toContain('Choose a time zone.')
+ ->toContain('aria-invalid="true"');
+});
diff --git a/tests/Feature/Components/SearchTest.php b/tests/Feature/Components/SearchTest.php
new file mode 100644
index 00000000..b46d4eab
--- /dev/null
+++ b/tests/Feature/Components/SearchTest.php
@@ -0,0 +1,39 @@
+blade(' ');
+
+ expect($html)
+ ->toContain('data-search')
+ ->toContain('role="search"')
+ ->toContain('type="search"')
+ ->toContain('placeholder="Search shares"')
+ ->toContain('aria-label="Search shares"')
+ ->toContain('aria-controls="find-view"')
+ ->toContain('aria-expanded="false"')
+ ->toContain('wire:model.live.debounce.300ms="query"')
+ ->toContain('id="find-view"')
+ ->toContain('data-search-clear')
+ ->toContain('aria-label="Back"')
+ ->toContain('materialSearch(false)')
+ ->not->toContain('data-search-results');
+});
+
+it('shows the results, or what to say when there are none', function () {
+ expect((string) $this->blade('holiday.zip '))
+ ->toContain('data-search-results')
+ ->toContain('holiday.zip ')
+ ->and((string) $this->blade('No shares match. '))
+ ->toContain('No shares match.');
+});
+
+it('names the input, trails the bar, and stays docked on request', function () {
+ $html = (string) $this->blade('AR ');
+
+ expect($html)
+ ->toContain('placeholder="Search"')
+ ->toContain('aria-label="Search your shares"')
+ ->toContain('data-search-trailing')
+ ->toContain('AR ')
+ ->toContain('materialSearch(true)');
+});