{{-- 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