Files
livewire-material/resources/views/components/choices.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 7aa6dc7d33 Take full on the searchable choice and the pickers, as on every field
`full` takes the 40rem bound off `[data-md-field]` from `medium`, and
`<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>` and `<x-file>`
pass it to the field they draw. `<x-choices searchable>`,
`<x-datepicker>` and `<x-timepicker>` draw the same field but did not
declare the prop, and their roots only forward `class` and `style`, so
`full` reached no element and the field stopped at 40rem: ReStride's
time zone choice stood narrower than the rows filling its card.

The three now declare `full` and pass it to their field (the date
picker's own field, not the dialog's entry fields). `<x-choices>`' chips
are not a field and have no bound, so they take `full` and render
nothing for it, and a call site can switch `searchable` on and off
without it. The docblocks and the skill list every component that takes
it.

The browser test that bounds a field at 600px now measures a bounded
and a `full` searchable choice, date picker and time picker too; it
fails without the change in Chrome, Firefox and Safari. A feature test
renders `full` on every field-shaped component and none on the chips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:04:12 +02:00

243 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, and `full` takes the 40rem bound off the field
as it does off every field. Chips have no such bound, so they take `full` and change nothing.
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.
The root renders `data-md-choices` (and `data-md-searchable`); the chips are `<x-chip-set>`'s, the
field `<x-field>`'s, and the open list M3's menu (resources/css/components/menu.css, as
`data-md-field-menu` and `data-md-field-option`). resources/css/components/choices.css brings
them together. --}}
@props([
'label' => null,
'hint' => null,
'icon' => null,
'options' => [],
'optionValue' => 'id',
'optionLabel' => 'name',
'single' => false,
'searchable' => false,
'variant' => null,
'placeholder' => null,
'value' => null,
'full' => false,
])
@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
data-md-choices
data-md-searchable
{{ $attributes->only(['class', 'style', '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-md-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 :$full>
<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 : ' ' }}"
data-md-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="if (open) $event.preventDefault(); close()"
x-on:keydown.tab="close()"
x-on:blur="close()"
/>
<x-slot:trailing>
<x-livewire-material::icon name="arrow_drop_down" data-md-field-trailing data-md-field-arrow />
</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 }}"
data-md-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-md-active="index === active ? '' : null"
x-on:mousedown.prevent="choose(option)"
x-on:mousemove="active = index"
data-md-field-option
>
<span x-text="option.label"></span>
<x-livewire-material::icon name="check" data-md-field-check />
</li>
</template>
<li x-show="filtered.length === 0" data-md-choices-empty>{{ __('Nothing matches') }}</li>
</ul>
</div>
@else
<div
data-md-choices
{{ $attributes->only(['class', 'style', '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