Shift+M and Shift+Y reach the month and year dropdowns, which is M3's date picker keyboard table; Home and End go to the ends of an open combobox list, and stay the field's own caret keys when it is closed. The password reveal drops aria-pressed and keeps only its flipping label, so a screen reader hears the state once rather than twice and inverted. The radio's `inline` now says that M3 cautions against a row and wants an option chosen on load. Plan step 20, findings IN-22, IN-23, IN-28 and IN-29. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
233 lines
11 KiB
PHP
233 lines
11 KiB
PHP
{{-- 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, Home
|
|
and End go to the ends of the open list (with it closed they are the field's own and move the
|
|
caret), Enter chooses, Escape puts the field back — WAI-ARIA's combobox keyboard, since M3
|
|
has no combobox. 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;
|
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
|
$messages = $errorKey !== null && isset($errors)
|
|
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
|
: [];
|
|
$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());
|
|
},
|
|
// The APG combobox puts Home and End at the ends of the open popup. With the popup
|
|
// closed they are the text field's own, and move the caret.
|
|
jump(event, last) {
|
|
if (! this.open || this.filtered.length === 0) return;
|
|
event.preventDefault();
|
|
this.active = last ? this.filtered.length - 1 : 0;
|
|
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-livewire-material::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.home="jump($event, false)"
|
|
x-on:keydown.end="jump($event, true)"
|
|
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-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
|
|
</x-slot:trailing>
|
|
</x-livewire-material::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-livewire-material::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-livewire-material::chip-set :$label :$hint :error-field="$model">
|
|
@foreach ($choices as $index => $choice)
|
|
<x-livewire-material::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-livewire-material::chip-set>
|
|
</div>
|
|
@endif
|