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
@@ -0,0 +1,65 @@
{{-- A set of M3 chips: a row that wraps, 8px apart, named for screen readers as a group.
<x-chip-set label="File types" hint="Show only these" error-field="kinds">
<x-chip type="filter" label="Photos" value="photos" wire:model.live="kinds" />
<x-chip type="filter" label="Documents" value="documents" wire:model.live="kinds" />
</x-chip-set>
`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
<div
role="group"
@if (filled($label)) aria-labelledby="material-chip-set-{{ $key }}-label" @endif
@if ($describedBy) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->class('min-w-0') }}
>
@if (filled($label))
<p id="material-chip-set-{{ $key }}-label" class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</p>
@endif
@if ($scroll)
<div
data-chip-set
x-data="materialChipSet"
wire:ignore.self
class="-mx-1.5 -my-2 flex scroll-px-6 gap-2 overflow-x-auto px-1.5 py-2 [scrollbar-width:none] [--chip-fade-end:0px] [--chip-fade-start:0px] data-scroll-end:[--chip-fade-end:1.5rem] data-scroll-start:[--chip-fade-start:1.5rem] [mask-image:linear-gradient(to_right,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)] rtl:[mask-image:linear-gradient(to_left,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)]"
>
{{ $slot }}
</div>
@else
<div data-chip-set class="flex flex-wrap gap-2">
{{ $slot }}
</div>
@endif
@if ($messages !== [])
<div id="{{ $describedBy }}">
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
@endforeach
</div>
@elseif (filled($hint))
<p id="{{ $describedBy }}" class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
@endif
</div>
+298
View File
@@ -0,0 +1,298 @@
{{-- An M3 chip assist, filter, input or suggestion in one component, picked by `type`.
<x-chip label="Add to calendar" icon="event" wire:click="addToCalendar" />
<x-chip type="filter" label="Photos" value="photos" wire:model.live="kinds" />
<x-chip type="input" label="anna@example.com" avatar="AM" removable wire:remove="removeRecipient(3)" wire:key="recipient-3" />
<x-chip type="suggestion" label="Sounds good" wire:click="reply('Sounds good')" />
`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 <label>". 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')
<span data-chip="input" @if ($removable) x-data="materialChip" x-on:keydown="removeOnKey($event)" @endif {{ $container }}>
<{{ $actionTag }} {{ $action }}>
@if ($avatar)
@if ($initials)
<span aria-hidden="true" @class(['me-2 grid size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container type-label-sm text-on-primary-container', 'opacity-38' => $disabled])>{{ $avatar }}</span>
@else
<img src="{{ $avatar }}" alt="" @class(['me-2 size-6 shrink-0 rounded-corner-full object-cover', 'opacity-38' => $disabled]) />
@endif
@elseif ($icon)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
@endif
<span data-chip-label class="truncate">{{ $label ?? $slot }}</span>
</{{ $actionTag }}>
@if ($removable)
<button
type="button"
@if (filled($text)) data-chip-remove aria-label="{{ __('Remove :label', ['label' => $text]) }}" @else data-chip-remove="{{ __('Remove :label') }}" @endif
@if ($wireRemove) wire:click="{{ $wireRemove }}" @endif
x-on:click="{{ $removeExpression }}"
@disabled($disabled)
@class([
'relative me-1.75 grid size-4.5 shrink-0 place-items-center rounded-corner-full',
'cursor-pointer focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary' => ! $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,
])
>
<x-icon name="close" class="size-4.5" />
</button>
@endif
@if (filled($name))
<input type="hidden" name="{{ $name }}" value="{{ $value ?? $text }}" />
@endif
@if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</span>
@else
<{{ $tag }} {{ $container }}>
@if ($checkbox)
<input {{ $control }} />
@endif
@if ($kind === 'filter')
{{-- The check grows in from nothing; beside an icon it takes the icon's place. --}}
<span @class([
'me-2 grid shrink-0 overflow-hidden *:col-start-1 *:row-start-1',
'size-4.5' => 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)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-4.5 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-0 group-aria-pressed/chip:opacity-0', $leadingInk])" />
@endif
<x-icon name="check" data-chip-check :class="$check" />
</span>
@elseif ($icon)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
@endif
<span class="truncate">{{ $label ?? $slot }}</span>
@if ($iconRight)
<x-icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
@endif
@if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</{{ $tag }}>
@endif
@@ -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)
<div
{{ $attributes->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
>
<div style="anchor-name: {{ $anchor }}">
<x-field :$id :$label :$hint :$messages :$icon :$variant>
<input
id="{{ $id }}"
type="text"
role="combobox"
autocomplete="off"
aria-autocomplete="list"
aria-controls="{{ $id }}-list"
aria-expanded="false"
x-bind:aria-expanded="open.toString()"
x-bind:aria-activedescendant="open && filtered[active] ? '{{ $id }}-option-' + active : null"
@if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
placeholder="{{ filled($placeholder) ? $placeholder : ' ' }}"
class="field-control"
x-model="query"
x-on:focus="show(); $nextTick(() => $el.select())"
x-on:pointerdown="selecting = document.activeElement !== $el"
x-on:click="if (selecting) { $el.select(); selecting = false; } if (! open) show();"
x-on:input="open = true; active = 0"
x-on:keydown.arrow-down.prevent="move(1)"
x-on:keydown.arrow-up.prevent="move(-1)"
x-on:keydown.enter.prevent="choose(filtered[active])"
x-on:keydown.escape="close()"
x-on:keydown.tab="close()"
x-on:blur="close()"
/>
<x-slot:trailing>
<x-icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
</x-slot:trailing>
</x-field>
</div>
<ul
id="{{ $id }}-list"
role="listbox"
popover="manual"
x-ref="list"
@if (filled($label)) aria-label="{{ $label }}" @endif
style="position-anchor: {{ $anchor }}"
class="field-menu"
>
<template x-for="(option, index) in filtered" :key="option.value">
<li
role="option"
x-bind:id="'{{ $id }}-option-' + index"
x-bind:aria-selected="(option.value === value).toString()"
x-bind:aria-disabled="option.disabled ? 'true' : null"
x-bind:data-active="index === active ? '' : null"
x-on:mousedown.prevent="choose(option)"
x-on:mousemove="active = index"
class="field-option"
>
<span x-text="option.label"></span>
<x-icon name="check" class="field-check size-6" />
</li>
</template>
<li x-show="filtered.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</li>
</ul>
</div>
@else
<div
{{ $attributes->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
>
<x-chip-set :$label :$hint :error-field="$model">
@foreach ($choices as $index => $choice)
<x-chip
type="filter"
:label="$choice['label']"
:selected="$isSelected($choice['value'])"
:disabled="$choice['disabled']"
x-bind:aria-pressed="selected({{ $index }}).toString()"
x-on:click="toggle({{ $index }})"
/>
@endforeach
</x-chip-set>
</div>
@endif
@@ -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 `<x-list-item>`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
`<input type="search">`. --}}
@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
<div
x-data="materialSearch({{ $docked ? 'true' : 'false' }})"
x-on:keydown.escape="if (open) { $event.stopPropagation(); close(true); }"
x-on:focusout="leave($event)"
x-on:pointerdown.outside="close()"
x-trap.noscroll="fullScreen"
x-bind:data-open="open ? '' : null"
x-bind:data-full-screen="fullScreen ? '' : null"
data-search
{{ $attributes->only(['class', 'wire:key'])->class(['relative']) }}
>
<div data-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
<span data-search-leading x-show="! fullScreen">
<x-icon :name="$icon" />
</span>
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close()" aria-label="{{ __('Back') }}">
<x-icon name="arrow_back" />
</button>
<input
{{ $attributes->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
/>
<button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
<x-icon name="close" />
</button>
@isset($trailing)
<span data-search-trailing>{{ $trailing }}</span>
@endisset
</div>
<div
x-ref="view"
id="{{ $id }}-view"
data-search-view
x-cloak
x-show="open"
x-on:keydown.arrow-down.prevent="step(1)"
x-on:keydown.arrow-up.prevent="step(-1)"
x-on:click="choose($event)"
>
@if ($slot->hasActualContent())
<div data-search-results>{{ $slot }}</div>
@elseif (isset($empty))
<div data-search-results><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div>
@endif
</div>
</div>