<x-widget.select>
Select
A styled select with keyboard support, type-to-jump and optional search. Single or multiple, and clearable. Options can carry meta (a balance, a count) shown beside them. With search-url it searches your app as you type, for lists too long to send to the page. Works with Livewire wire:model, including options that change between renders.
php artisan larawell:add select
Usage
Search endpoint
The URL in search-url answers GET ?q=… with JSON: a list, or a resource collection ({"data": [...]}). Each option is a string, or value and label, with optional meta and disabled. Pass the chosen record(s) in options so the field shows them, and validate the value on submit as usual (exists:customers,id).
Route::get('/customers/search', function (Request $request) {
$q = $request->validate(['q' => ['required', 'string', 'max:100']])['q'];
return Customer::query()
->where('name', 'like', '%'.addcslashes($q, '%_\\').'%')
->orderBy('name')
->limit(20)
->get()
->map(fn (Customer $customer): array => ['value' => $customer->id, 'label' => $customer->name, 'meta' => $customer->email]);
})->middleware('throttle:120,1')->name('customers.search');
Livewire
In a Livewire component, bind with wire:model (deferred) or wire:model.live, single or multiple. Without :value the select shows the bound property, so it keeps the choice through every render, and setting or clearing the property in PHP updates it. Options may change between renders: here the cities follow the country (reset $city in updatedCountry()).
<div>
<x-widget.select name="country" label="Country" :options="$countries" wire:model.live="country" />
<x-widget.select name="city" label="City" placeholder="Pick a city" :options="$cities" wire:model.live="city" searchable :search-min="8" />
<x-widget.select name="tags" label="Tags" multiple display="chips" clearable :options="$tags" wire:model="selectedTags" />
<button type="button" wire:click="save">Save</button>
</div>
Examples
Select
Options can be a plain list, a key => label map, or rows with id, name and extra meta, which shows beside each option in the list and beside the choice. Long lists get a search box with searchable.
Show code Hide code
<div class="grid gap-6 sm:grid-cols-2">
<x-widget.select name="network" label="Network" placeholder="Choose a network" value="trc20" :options="[
['id' => 'trc20', 'name' => 'TRC20 (Tron)', 'formattedBalance' => '1,250.00 USDT'],
['id' => 'erc20', 'name' => 'ERC20 (Ethereum)', 'formattedBalance' => '310.45 USDT'],
['id' => 'bep20', 'name' => 'BEP20 (BNB Chain)', 'formattedBalance' => '0.00 USDT', 'disabled' => true],
]" />
<x-widget.select name="country" label="Country" placeholder="Select a country" searchable search-placeholder="Search countries" :options="[
'MY' => 'Malaysia', 'SG' => 'Singapore', 'ID' => 'Indonesia', 'TH' => 'Thailand', 'VN' => 'Vietnam',
'PH' => 'Philippines', 'IN' => 'India', 'JP' => 'Japan', 'KR' => 'South Korea', 'AU' => 'Australia',
'NZ' => 'New Zealand', 'GB' => 'United Kingdom', 'US' => 'United States', 'AE' => 'United Arab Emirates',
]" />
</div>
Multi select
multiple submits networks[] and keeps the list open while you pick. display sets how the choices show: chips (each removable; Backspace removes the last), count ("3 selected", full list in the tooltip) or list (joined, the default). clearable adds a button to empty it. Listen for the select-change event to react in JS.
Show code Hide code
@php
$networks = [
'trc20' => 'TRC20 (Tron)',
'erc20' => 'ERC20 (Ethereum)',
'bep20' => 'BEP20 (BNB Chain)',
'sol' => 'Solana',
'arb' => 'Arbitrum',
];
@endphp
<div class="grid gap-6 sm:grid-cols-2">
<x-widget.select name="networks" label="Networks" placeholder="Choose networks" multiple clearable display="chips" :value="['trc20', 'erc20', 'bep20']" :options="$networks" class="sm:col-span-2" />
<x-widget.select name="alert_networks" label="Alert me on" placeholder="Any network" multiple display="count" count-label=":count networks" :value="['trc20', 'sol', 'arb']" :options="$networks" />
<x-widget.select name="payout_networks" label="Payout networks" placeholder="Choose networks" multiple :value="['trc20', 'bep20']" :options="$networks" />
</div>
Search your app
For lists too long to send to the page (customers, products), search-url asks your app as you type and shows what it answers. Pass the chosen one in options, so the field shows it before any search. Try "an", or part of an email.
Show code Hide code
<x-widget.select name="customer_id" label="Customer" placeholder="Choose a customer" class="max-w-md"
search-url="{{ route('customers.search') }}"
value="7"
:options="[['value' => 7, 'label' => 'Ana Silva', 'meta' => 'ana@example.com']]" />
Search your app for several
With multiple, choices stay picked while you search for more. Options you pass that aren't chosen show until you type: here, a few people to start from.
Show code Hide code
<x-widget.select name="assignees" label="Assign to" placeholder="Search people" multiple display="chips" class="max-w-md"
search-url="{{ route('customers.search') }}"
:value="[9]"
:options="[
['value' => 9, 'label' => 'Ben Carter', 'meta' => 'ben@example.com'],
['value' => 27, 'label' => 'Priya Sharma', 'meta' => 'priya@example.com'],
['value' => 11, 'label' => 'Chloé Martin', 'meta' => 'chloe@example.com'],
]" />
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.select>
| Prop | Default | Description |
|---|---|---|
| name |
null
|
What the value submits as; with multiple, name[] (a trailing [] is added for you). |
| id |
null
|
Defaults to one made from the name. |
| label |
null
|
Shown above the field (or inside it, with inner-label), and its name for screen readers. |
| options |
[]
|
A list (['Red', 'Blue']), a value => label map, or rows: ['value' => 1, 'label' => 'Savings', 'meta' => '$120', 'disabled' => true]; id and name work as value and label. meta shows at the end of the option's line, e.g. a count. |
| value |
null
|
The chosen value (an array with multiple). Old input wins after a failed submit. With wire:model and no value, it comes from the bound Livewire property. |
| placeholder |
''
|
Shown while nothing is chosen. |
| error |
null
|
An error message of your own; otherwise the validation error for the name, from the session. |
| info |
null
|
A hint under the field. |
| bag |
'default'
|
Which error bag to read the error from. |
| disabled |
false
|
Greyed out: it can't be opened or changed (its value still submits). |
| searchable |
false
|
A search box at the top of the list, once there are at least search-min options. |
| search-min |
12
|
How many options it takes before searchable shows the search box. |
| search-placeholder |
'Search'
|
The search box's placeholder. |
| no-results |
'No results'
|
Shown when the search matches nothing. |
| search-url |
null
|
Searches your app instead of the options on the page, for lists too long to send: a URL that answers GET ?q=… with JSON, a list or {"data": [...]}, each option a string or ['value' => 7, 'label' => 'Ana Silva', 'meta' => …]. Pass the chosen option(s) in options, so the field shows them before any search; any others there show until you type. |
| search-min-length |
2
|
With search-url: how many characters to type before asking. |
| searching |
'Searching…'
|
With search-url: shown, and announced, while a search runs. |
| search-failed |
'Couldn\'t search. Try again.'
|
With search-url: shown, and announced, when a search fails. |
| inner-label |
false
|
The label inside the field, above the value, instead of above the field. |
| multiple |
false
|
Choose several: the list stays open and each pick toggles an option. |
| clearable |
false
|
An × button that clears the choice. |
| display |
'list'
|
With multiple, how the choices show: list (labels joined), chips (removable) or count ("3 selected"). |
| count-label |
':count selected'
|
display="count": the text, with :count for the number. |
| required-message |
'Choose an option.'
|
With required: the message when nothing is chosen. |
Accessibility
All 4 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.
Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.
Source
What larawell:add select writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds and Field, and the theme and base CSS.
resources/views/components/widget/select/index.blade.php Show
@props([
// What the value submits as; with multiple, name[] (a trailing [] is added for you).
'name' => null,
// Defaults to one made from the name.
'id' => null,
// Shown above the field (or inside it, with inner-label), and its name for screen readers.
'label' => null,
// A list (['Red', 'Blue']), a value => label map, or rows: ['value' => 1, 'label' => 'Savings', 'meta' => '$120',
// 'disabled' => true]; id and name work as value and label. meta shows at the end of the option's line, e.g. a count.
'options' => [],
// The chosen value (an array with multiple). Old input wins after a failed submit. With wire:model and no value,
// it comes from the bound Livewire property.
'value' => null,
// Shown while nothing is chosen.
'placeholder' => '',
// An error message of your own; otherwise the validation error for the name, from the session.
'error' => null,
// A hint under the field.
'info' => null,
// Which error bag to read the error from.
'bag' => 'default',
// Greyed out: it can't be opened or changed (its value still submits).
'disabled' => false,
// A search box at the top of the list, once there are at least search-min options.
'searchable' => false,
// How many options it takes before searchable shows the search box.
'searchMin' => 12,
// The search box's placeholder.
'searchPlaceholder' => 'Search',
// Shown when the search matches nothing.
'noResults' => 'No results',
// Searches your app instead of the options on the page, for lists too long to send: a URL that answers GET ?q=… with
// JSON, a list or {"data": [...]}, each option a string or ['value' => 7, 'label' => 'Ana Silva', 'meta' => …]. Pass the
// chosen option(s) in options, so the field shows them before any search; any others there show until you type.
'searchUrl' => null,
// With search-url: how many characters to type before asking.
'searchMinLength' => 2,
// With search-url: shown, and announced, while a search runs.
'searching' => 'Searching…',
// With search-url: shown, and announced, when a search fails.
'searchFailed' => 'Couldn\'t search. Try again.',
// The label inside the field, above the value, instead of above the field.
'innerLabel' => false,
// Choose several: the list stays open and each pick toggles an option.
'multiple' => false,
// An × button that clears the choice.
'clearable' => false,
// With multiple, how the choices show: list (labels joined), chips (removable) or count ("3 selected").
'display' => 'list',
// display="count": the text, with :count for the number.
'countLabel' => ':count selected',
// With required: the message when nothing is chosen.
'requiredMessage' => 'Choose an option.',
])
@php
$field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'select', attributes: $attributes);
$id = $field->id;
// multiple submits name[] (one hidden input per choice); a single select submits name. name="tags" and
// name="tags[]" both mean tags[], so a trailing [] isn't doubled into tags[][].
$inputName = $multiple && $name !== null ? preg_replace('/\[\]$/', '', $name) : $name;
// Flattened, so old input that arrived nested (from a page that still sent tags[][]) can't crash the render.
// Enum values (model casts) submit their backing value.
$selectedValues = array_map(
static fn (mixed $v): string => (string) ($v instanceof \BackedEnum ? $v->value : $v),
array_values(array_filter(
\Illuminate\Support\Arr::flatten(\Illuminate\Support\Arr::wrap($field->old($value))),
static fn (mixed $v): bool => $v instanceof \BackedEnum || (is_scalar($v) && $v !== ''),
)),
);
if (! $multiple) {
$selectedValues = array_slice($selectedValues, 0, 1);
}
$selectedValue = $selectedValues[0] ?? null;
// Accepts ['a', 'b'], ['value' => 'Label'], or rows like ['id' => 1, 'name' => 'X', 'formattedBalance' => '…'].
$isList = array_is_list($options);
$items = collect($options)->map(function (mixed $option, int|string $key) use ($isList): array {
if (is_array($option)) {
$optionValue = (string) ($option['value'] ?? $option['id'] ?? $key);
return [
'value' => $optionValue,
'label' => (string) ($option['label'] ?? $option['name'] ?? $optionValue),
'meta' => $option['meta'] ?? $option['formattedBalance'] ?? null,
'disabled' => ! empty($option['disabled']),
];
}
return ['value' => (string) ($isList ? $option : $key), 'label' => (string) $option, 'meta' => null, 'disabled' => false];
})->values();
$chosen = $items->whereIn('value', $selectedValues)->values();
$selected = $multiple ? null : $chosen->first();
// One label, or several joined for multiple (the full list is also the tooltip).
$shown = $chosen->isNotEmpty() ? $chosen->pluck('label')->implode(', ') : null;
// How a multiple select shows its choices: list (joined), chips (one removable chip each) or count ("3 selected").
// A typo fails loudly, naming the values that work, instead of quietly rendering something else.
if (! in_array($display, ['list', 'chips', 'count'], true)) {
throw new \InvalidArgumentException("Unknown display [{$display}] for <x-widget.select>. Use one of: list, chips, count.");
}
$display = $multiple ? $display : 'list';
$visible = $display === 'count' && $chosen->count() > 1 ? str_replace(':count', (string) $chosen->count(), $countLabel) : $shown;
// A short list doesn't need a search box, same threshold as the React version. Searching the server always does.
$remote = $searchUrl !== null && $searchUrl !== '';
$showSearch = $remote || ($searchable && $items->count() >= (int) $searchMin);
// One look for every option, the server's and those a search brings (the template below).
$optionClass = 'flex shrink-0 cursor-pointer items-center justify-between gap-3 px-5 py-2 transition-colors duration-150 select-none aria-disabled:cursor-not-allowed aria-disabled:opacity-40 aria-selected:bg-primary/10 data-active:bg-field aria-selected:data-active:bg-primary/15';
@endphp
{{-- data-required-message: the value is in hidden inputs, which the browser never validates, so resources/js/widget/field
stops an empty submit itself and shows this. --}}
<x-widget.field :required="$attributes->has('required')" :data-required-message="$attributes->has('required') && ! $disabled ? $requiredMessage : false" data-select :data-multiple="$multiple ? 'true' : false" :data-search-url="$remote ? $searchUrl : false" :data-search-min-length="$remote ? max(1, (int) $searchMinLength) : false" :data-display="$display" :data-count-label="$display === 'count' ? $countLabel : false" :id="$id" :label="$innerLabel ? null : $label" :error="$field->errors" :info="$info" :disabled="$disabled" box="relative min-h-12 items-stretch" :class="$attributes->get('class')">
@if ($display === 'chips')
{{-- Chips sit above the trigger, which fills the box behind them: clicking any empty spot opens the list,
and only the chips' remove buttons take clicks of their own (a button can't sit inside the trigger). --}}
<div data-select-chips @class(['pointer-events-none relative z-10 flex min-w-0 flex-1 flex-wrap items-center gap-1.5 py-2.5 ps-3', 'pe-24' => $clearable, 'pe-14' => ! $clearable])>
@foreach ($chosen as $item)
<span data-select-chip class="bg-primary/10 text-primary inline-flex max-w-full items-center gap-1 rounded-full py-1 ps-3 pe-1 text-sm">
<span data-select-chip-label class="truncate">{{ $item['label'] }}</span>
<button type="button" data-select-chip-remove data-value="{{ $item['value'] }}" aria-label="Remove {{ $item['label'] }}" @disabled($disabled) class="hover:bg-primary/20 focus-visible:ring-primary pointer-events-auto grid size-6 place-items-center rounded-full outline-none focus-visible:ring-2"><x-widget.icon name="x" class="size-3.5" /></button>
</span>
@endforeach
<span data-select-chips-placeholder @if ($chosen->isNotEmpty()) hidden @endif class="text-muted ps-2">{{ $placeholder }}</span>
</div>
<template data-select-chip-template>
<span data-select-chip class="bg-primary/10 text-primary inline-flex max-w-full items-center gap-1 rounded-full py-1 ps-3 pe-1 text-sm">
<span data-select-chip-label class="truncate"></span>
<button type="button" data-select-chip-remove class="hover:bg-primary/20 focus-visible:ring-primary pointer-events-auto grid size-6 place-items-center rounded-full outline-none focus-visible:ring-2"><x-widget.icon name="x" class="size-3.5" /></button>
</span>
</template>
@endif
{{-- The trigger opens the list natively via popovertarget; the JS adds keyboard, search and positioning. --}}
<button
type="button"
id="{{ $id }}"
role="combobox"
popovertarget="{{ $id }}-popup"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="{{ $id }}-listbox"
@if ($attributes->has('required')) aria-required="true" @endif
@if ($innerLabel && $label) aria-label="{{ $label }}" @endif
{{ $field->aria($attributes, (bool) $info) }}
@disabled($disabled)
data-select-trigger
@class([
'group/trigger flex min-w-0 text-start outline-none disabled:cursor-not-allowed disabled:opacity-70',
'w-full flex-col justify-center gap-1 px-5 py-3' => $display !== 'chips',
'focus-visible:ring-primary absolute inset-0 items-center justify-end rounded-[20px] pe-5 focus-visible:ring-2 focus-visible:ring-inset' => $display === 'chips',
])
>
@if ($innerLabel && $label && $display !== 'chips')
<span @class(['font-semibold', 'text-muted' => $disabled])>{{ $label }}</span>
@endif
<span @class(['flex items-center justify-between gap-2.5', 'w-full' => $display !== 'chips'])>
{{-- With chips, this is the value screen readers hear; the chips show it visually. --}}
<span
data-select-display
data-placeholder="{{ $placeholder }}"
@if (! $shown) data-empty @endif
@if ($shown) title="{{ $shown }}" @endif
@class(['data-empty:text-muted group-data-invalid/field:data-empty:text-error min-w-0 truncate', 'sr-only' => $display === 'chips'])
>{{ ($display === 'chips' ? $shown : $visible) ?? $placeholder }}</span>
<span class="flex shrink-0 items-center gap-2.5">
<span data-select-meta class="text-foreground/60 text-xs whitespace-nowrap">{{ $selected['meta'] ?? '' }}</span>
{{-- Room for the clear button, which sits over this gap (it can't go inside the trigger). --}}
@if ($clearable)
<span class="size-7" aria-hidden="true"></span>
@endif
<x-widget.icon name="chevron-down" @class(['size-5 transition-transform group-aria-expanded/trigger:-rotate-180', 'opacity-40' => $disabled]) />
</span>
</span>
</button>
{{-- Sits beside the trigger rather than in it: a button inside a button isn't valid HTML. --}}
@if ($clearable)
{{-- Centred on the value's line: the box's middle, or with an inner label, half the label's line (and gap) lower. --}}
<button type="button" data-select-clear aria-label="Clear selection" @if (! $shown) hidden @endif @disabled($disabled)
@class([
'text-foreground/60 hover:text-foreground focus-visible:ring-primary absolute end-[3.125rem] top-1/2 z-20 grid size-7 place-items-center rounded-full outline-none focus-visible:ring-2',
'-translate-y-1/2' => ! ($innerLabel && $label && $display !== 'chips'),
'translate-y-[calc(-50%+0.75rem)]' => $innerLabel && $label && $display !== 'chips',
])>
<x-widget.icon name="x" class="size-4" />
</button>
@endif
@if ($multiple)
{{-- One hidden input per choice, rebuilt by the script; nothing is sent when none is chosen. --}}
<div data-select-values data-name="{{ $inputName }}" @if ($attributes->has('form')) data-form="{{ $attributes->get('form') }}" @endif>
@foreach ($selectedValues as $chosenValue)
<input type="hidden" @if ($inputName) name="{{ $inputName }}[]" @endif value="{{ $chosenValue }}" {{ $attributes->only(['form']) }}>
@endforeach
</div>
@if ($field->bindings($attributes)->except(['form'])->isNotEmpty())
{{-- wire:model and x-model need one element that holds the whole array: a hidden native multi-select, kept
in step by the script. Unnamed, so the form still submits the inputs above, once. --}}
<select multiple hidden tabindex="-1" aria-hidden="true" data-select-model {{ $field->bindings($attributes)->except(['form']) }}>
@foreach ($items as $item)
<option value="{{ $item['value'] }}" @selected(in_array($item['value'], $selectedValues, true))></option>
@endforeach
</select>
@endif
@else
<input type="hidden" @if ($name) name="{{ $name }}" @endif value="{{ $selectedValue }}" data-select-input {{ $field->forwarded($attributes)->except(['required']) }}>
@endif
<div
id="{{ $id }}-popup"
popover
data-select-popup
class="border-line bg-surface text-foreground fixed inset-auto m-0 overflow-hidden rounded-2xl border p-0 text-sm font-medium shadow-lg"
>
@if ($showSearch)
<div class="border-line border-b p-2">
{{-- A fixed id, not a generated one: Livewire's morph matches elements by id, and a new id each render
would swap in a fresh box without the script's listeners. --}}
<x-widget.search
:id="$id.'-search'"
size="sm"
:placeholder="$searchPlaceholder"
data-select-search
role="searchbox"
aria-controls="{{ $id }}-listbox"
aria-autocomplete="list"
autocomplete="off"
/>
</div>
@endif
<div
id="{{ $id }}-listbox"
role="listbox"
@if ($multiple) aria-multiselectable="true" @endif
aria-label="{{ $label ?: $placeholder }}"
class="flex max-h-96 flex-col overflow-y-auto overscroll-contain [scrollbar-width:thin]"
>
@foreach ($items as $i => $item)
<div
id="{{ $id }}-option-{{ $i }}"
role="option"
data-value="{{ $item['value'] }}"
data-label="{{ $item['label'] }}"
data-meta="{{ $item['meta'] }}"
title="{{ $item['label'] }}"
aria-selected="{{ in_array($item['value'], $selectedValues, true) ? 'true' : 'false' }}"
@if ($item['disabled']) aria-disabled="true" @endif
class="{{ $optionClass }}"
{{-- The meta (a balance, a count) at the end of the line; an empty span without one, so it can be filled in later. --}}
><span class="min-w-0 truncate">{{ $item['label'] }}</span><span data-select-option-meta class="text-foreground/60 shrink-0 text-xs tabular-nums empty:hidden">{{ $item['meta'] }}</span></div>
@endforeach
<div data-select-empty data-no-results="{{ $noResults }}" @if ($remote) data-searching="{{ $searching }}" data-search-failed="{{ $searchFailed }}" @endif @if ($items->isNotEmpty()) hidden @endif class="text-muted px-5 py-2">{{ $noResults }}</div>
</div>
@if ($remote)
{{-- What a screen reader hears as a search runs, finds or fails. --}}
<p data-select-status role="status" class="sr-only"></p>
{{-- An option a search brings, filled in by the script: the same look as the server's, kept here so you can
restyle both in the Blade you own. --}}
<template data-select-option-template>
<div role="option" aria-selected="false" class="{{ $optionClass }}"><span data-select-option-label class="min-w-0 truncate"></span><span data-select-option-meta class="text-foreground/60 shrink-0 text-xs tabular-nums empty:hidden"></span></div>
</template>
@endif
</div>
</x-widget.field>
resources/js/widget/select/index.js Show
// Drives <x-widget.select>. Open/close, Esc and click-outside come from the native popover;
// this adds keyboard navigation, type-to-jump, search filtering (or, with search-url, searching your app) and positioning
// under the field.
import { closeIfOutOfView } from '../field';
const VIEWPORT_EDGE = 16;
const GAP = 4;
// Set up once per element. Not a data attribute: Livewire's morph removes attributes the server didn't render, and a
// second set-up would add every listener twice.
const ready = new WeakSet();
function initSelect(root) {
if (ready.has(root)) {
return;
}
ready.add(root);
const trigger = root.querySelector('[data-select-trigger]');
const input = root.querySelector('[data-select-input]');
const display = root.querySelector('[data-select-display]');
const meta = root.querySelector('[data-select-meta]');
const popup = root.querySelector('[data-select-popup]');
const search = popup.querySelector('[data-select-search]');
const empty = popup.querySelector('[data-select-empty]');
// Read each time, not once: a Livewire render or other script can replace the options (a city list that follows
// the country), and the new ones must be pickable.
const options = () => [...popup.querySelectorAll('[role="option"]')];
// multiple: picking toggles an option and the list stays open; each choice is its own hidden name[] input.
const multiple = root.dataset.multiple === 'true';
const values = root.querySelector('[data-select-values]');
// multiple with wire:model or x-model: the hidden native multi-select they're bound to.
const model = root.querySelector('[data-select-model]');
const clearButton = root.querySelector('[data-select-clear]');
// multiple only: list (labels joined), chips (one removable chip each) or count ("3 selected").
const displayMode = root.dataset.display ?? 'list';
const chips = root.querySelector('[data-select-chips]');
const chipTemplate = root.querySelector('[data-select-chip-template]');
// The field frame's box, so the list lines up with the whole field rather than just the button.
const anchor = trigger.parentElement;
let active = -1;
let typed = '';
let typedTimer = null;
// search-url: typing asks your app, and its answers become the options. What's chosen stays in the list (hidden
// when it doesn't match), so it's never lost; under search-min-length, the options the server drew show again.
const searchUrl = root.dataset.searchUrl;
const listbox = popup.querySelector('[role="listbox"]');
const optionTemplate = popup.querySelector('template[data-select-option-template]');
const status = popup.querySelector('[data-select-status]');
// The options the server drew, in its order: what shows before any search, along with whatever is chosen.
const drawnOrder = options();
const drawn = new Set(drawnOrder.map((option) => option.dataset.value));
const answers = new Map();
let searchTimer = null;
let searchRequest = null;
let made = 0;
const isOpen = () => popup.matches(':popover-open');
const visible = () => options().filter((option) => !option.hidden);
// With a search box, focus lives there; otherwise it stays on the trigger.
const focusOwner = () => search ?? trigger;
function setActive(index) {
const list = visible();
options().forEach((option) => option.removeAttribute('data-active'));
active = list[index] ? index : -1;
if (active === -1) {
focusOwner().removeAttribute('aria-activedescendant');
return;
}
list[active].setAttribute('data-active', '');
list[active].scrollIntoView({ block: 'nearest' });
focusOwner().setAttribute('aria-activedescendant', list[active].id);
}
const isEnabled = (option) => !option.hasAttribute('aria-disabled');
// Next enabled option from `from` in `step` direction; stays put when there isn't one.
function move(step, from = active) {
const list = visible();
for (let i = from + step; i >= 0 && i < list.length; i += step) {
if (isEnabled(list[i])) {
setActive(i);
return;
}
}
}
const chosen = () => options().filter((option) => option.getAttribute('aria-selected') === 'true');
function drawChips(picked) {
chips.querySelectorAll('[data-select-chip]').forEach((chip) => chip.remove());
const placeholder = chips.querySelector('[data-select-chips-placeholder]');
for (const option of picked) {
const chip = chipTemplate.content.firstElementChild.cloneNode(true);
const remove = chip.querySelector('[data-select-chip-remove]');
chip.querySelector('[data-select-chip-label]').textContent = option.dataset.label;
remove.dataset.value = option.dataset.value;
remove.setAttribute('aria-label', `Remove ${option.dataset.label}`);
remove.disabled = trigger.disabled;
placeholder.before(chip);
}
placeholder.hidden = picked.length > 0;
}
// Redraws the trigger from the options' aria-selected: one label, several joined, a count, or chips.
function refresh() {
const picked = chosen();
const text = picked.map((option) => option.dataset.label).join(', ');
const shown = displayMode === 'count' && picked.length > 1 ? root.dataset.countLabel.replace(':count', String(picked.length)) : text;
display.textContent = shown || display.dataset.placeholder;
display.toggleAttribute('data-empty', !text);
text ? (display.title = text) : display.removeAttribute('title');
if (chips) {
drawChips(picked);
}
meta.textContent = multiple ? '' : (picked[0]?.dataset.meta ?? '');
if (clearButton) {
clearButton.hidden = picked.length === 0;
}
}
// Writes the choice to the hidden input(s) and announces it.
function commit() {
const picked = chosen();
const target = multiple ? (model ?? values) : input;
if (model) {
// A choice a search brought isn't among the bound select's options yet; add it, or it couldn't be selected.
picked.filter((option) => ![...model.options].some((item) => item.value === option.dataset.value))
.forEach((option) => model.add(new Option('', option.dataset.value)));
const chosenValues = new Set(picked.map((option) => option.dataset.value));
[...model.options].forEach((item) => {
item.selected = chosenValues.has(item.value);
});
}
if (multiple) {
values.replaceChildren(...picked.map((option) => {
const hidden = Object.assign(document.createElement('input'), {
type: 'hidden',
name: values.dataset.name ? `${values.dataset.name}[]` : '',
value: option.dataset.value,
});
// A select outside its <form> (form="…") keeps submitting with it, as the server-rendered inputs did.
if (values.dataset.form) {
hidden.setAttribute('form', values.dataset.form);
}
return hidden;
}));
} else {
input.value = picked[0]?.dataset.value ?? '';
}
// Both events: tools like Alpine x-model and Livewire wire:model listen for `input`, plain forms for `change`.
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
root.dispatchEvent(new CustomEvent('select-change', {
bubbles: true,
detail: multiple
? { values: picked.map((option) => option.dataset.value), labels: picked.map((option) => option.dataset.label) }
: { value: picked[0]?.dataset.value ?? '', label: picked[0]?.dataset.label ?? '' },
}));
}
function choose(option) {
if (!option || option.hasAttribute('aria-disabled')) {
return;
}
if (multiple) {
option.setAttribute('aria-selected', String(option.getAttribute('aria-selected') !== 'true'));
refresh();
commit();
return;
}
options().forEach((item) => item.setAttribute('aria-selected', String(item === option)));
refresh();
commit();
popup.hidePopover();
trigger.focus();
}
// A chip's own remove button, and Backspace on the closed field for the last chip.
const unpick = (option) => {
option?.setAttribute('aria-selected', 'false');
refresh();
commit();
};
chips?.addEventListener('click', (event) => {
const remove = event.target.closest('[data-select-chip-remove]');
if (remove) {
unpick(options().find((option) => option.dataset.value === remove.dataset.value));
trigger.focus();
}
});
trigger.addEventListener('keydown', (event) => {
if (chips && event.key === 'Backspace' && !isOpen()) {
unpick(chosen().at(-1));
}
});
clearButton?.addEventListener('click', () => {
options().forEach((option) => option.setAttribute('aria-selected', 'false'));
refresh();
commit();
trigger.focus();
});
// An option as your app sent it: a string, or value and label (id and name work too), with meta and disabled.
function normalise(item) {
const entry = typeof item === 'string' || typeof item === 'number' ? { value: item, label: item } : (item ?? {});
const value = String(entry.value ?? entry.id ?? entry.label ?? '');
return { value, label: String(entry.label ?? entry.name ?? value), meta: entry.meta ?? '', disabled: Boolean(entry.disabled) };
}
function optionFor(item) {
const option = optionTemplate.content.firstElementChild.cloneNode(true);
made += 1;
option.id = `${listbox.id.replace(/-listbox$/, '')}-found-${made}`;
Object.assign(option.dataset, { value: item.value, label: item.label, meta: item.meta });
option.title = item.label;
option.querySelector('[data-select-option-label]').textContent = item.label;
option.querySelector('[data-select-option-meta]').textContent = item.meta;
if (item.disabled) {
option.setAttribute('aria-disabled', 'true');
}
return option;
}
function say(text) {
if (status) {
status.textContent = text;
}
}
// Shows the options for these answers, in their order; null brings back the ones the server drew, in its order,
// and whatever is chosen.
function showFound(items) {
const wanted = new Map((items ?? []).map((item) => [item.value, item]));
if (!items) {
drawnOrder.filter((option) => option.isConnected).forEach((option) => empty.before(option));
options().filter((option) => !drawn.has(option.dataset.value)).forEach((option) => empty.before(option));
}
options().forEach((option) => {
const picked = option.getAttribute('aria-selected') === 'true';
const keep = items ? wanted.has(option.dataset.value) : drawn.has(option.dataset.value) || picked;
if (!keep && !picked && !drawn.has(option.dataset.value)) {
option.remove();
} else {
option.hidden = !keep;
}
});
for (const item of wanted.values()) {
const option = options().find((existing) => existing.dataset.value === item.value) ?? optionFor(item);
option.hidden = false;
empty.before(option);
}
empty.textContent = empty.dataset.noResults;
empty.hidden = visible().length > 0;
setActive(-1);
move(1, -1);
if (items) {
say(items.length === 0 ? empty.dataset.noResults : '');
}
}
async function searchApp(query) {
const term = query.trim();
clearTimeout(searchTimer);
searchRequest?.abort();
listbox.removeAttribute('aria-busy');
if (term.length < Number(root.dataset.searchMinLength ?? 2)) {
showFound(null);
say('');
return;
}
if (answers.has(term)) {
showFound(answers.get(term));
return;
}
searchTimer = setTimeout(async () => {
const request = new AbortController();
searchRequest = request;
listbox.setAttribute('aria-busy', 'true');
if (visible().length === 0) {
empty.textContent = empty.dataset.searching;
empty.hidden = false;
}
say(empty.dataset.searching);
const url = new URL(searchUrl, window.location.href);
url.searchParams.set('q', term);
try {
const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: request.signal });
if (!response.ok) {
throw new Error(String(response.status));
}
const body = await response.json();
const items = (Array.isArray(body) ? body : (Array.isArray(body?.data) ? body.data : [])).map(normalise).filter((item) => item.value !== '');
answers.set(term, items);
// Typing may have moved on while this was on its way; only the latest answer counts.
if (search.value.trim() === term) {
showFound(items);
}
} catch (error) {
if (error.name !== 'AbortError') {
options().forEach((option) => { option.hidden = true; });
empty.textContent = empty.dataset.searchFailed;
empty.hidden = false;
say(empty.dataset.searchFailed);
}
} finally {
if (searchRequest === request) {
listbox.removeAttribute('aria-busy');
}
}
}, 250);
}
function filter(query) {
if (searchUrl) {
searchApp(query);
return;
}
const needle = query.trim().toLowerCase();
options().forEach((option) => {
option.hidden = needle !== '' && !option.dataset.label.toLowerCase().includes(needle);
});
empty.hidden = visible().length > 0;
setActive(-1);
move(1, -1);
}
function position() {
if (closeIfOutOfView(anchor, popup)) {
return;
}
const box = anchor.getBoundingClientRect();
popup.style.width = `${box.width}px`;
const height = popup.offsetHeight;
const below = box.bottom + GAP;
const above = box.top - GAP - height;
const fitsBelow = below + height <= window.innerHeight - VIEWPORT_EDGE;
popup.style.top = `${fitsBelow || above < VIEWPORT_EDGE ? below : above}px`;
popup.style.left = `${box.left}px`;
}
// Native <select> behaviour: typing letters jumps to the first matching option.
function typeahead(character) {
typed += character.toLowerCase();
clearTimeout(typedTimer);
typedTimer = setTimeout(() => { typed = ''; }, 500);
const list = visible();
const match = list.findIndex((option) => isEnabled(option) && option.dataset.label.toLowerCase().startsWith(typed));
if (match !== -1) {
setActive(match);
}
}
function handleListKeys(event) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
isOpen() ? move(1) : popup.showPopover();
break;
case 'ArrowUp':
event.preventDefault();
isOpen() ? move(-1) : popup.showPopover();
break;
case 'Home':
case 'End':
if (isOpen()) {
event.preventDefault();
// Start just outside the list so the first/last enabled option wins.
event.key === 'Home' ? move(1, -1) : move(-1, visible().length);
}
break;
case 'Enter':
// Stops the button's native popover toggle so Enter picks instead. While the list is open, Enter
// never falls through, even with nothing highlighted: in the search box it would submit the form.
if (isOpen()) {
event.preventDefault();
if (active !== -1) {
choose(visible()[active]);
}
}
break;
case 'Tab':
if (isOpen()) {
popup.hidePopover();
}
break;
default:
return false;
}
return true;
}
trigger.addEventListener('keydown', (event) => {
if (handleListKeys(event)) {
return;
}
// Space in the middle of type-ahead ("new z…") is part of the search, as in a native select; otherwise it picks.
if (event.key === ' ' && isOpen() && typed !== '') {
event.preventDefault();
typeahead(' ');
} else if (event.key === ' ' && isOpen() && active !== -1) {
event.preventDefault();
choose(visible()[active]);
} else if (isOpen() && event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey) {
typeahead(event.key);
}
});
search?.addEventListener('keydown', handleListKeys);
search?.addEventListener('input', () => filter(search.value));
popup.addEventListener('click', (event) => {
const option = event.target.closest('[role="option"]');
if (option) {
choose(option);
}
});
popup.addEventListener('mousemove', (event) => {
const option = event.target.closest('[role="option"]:not([aria-disabled])');
if (option && !option.hasAttribute('data-active')) {
setActive(visible().indexOf(option));
}
});
// Keep focus on the trigger/search while clicking an option.
popup.addEventListener('mousedown', (event) => {
if (event.target.closest('[role="option"]')) {
event.preventDefault();
}
});
popup.addEventListener('beforetoggle', (event) => {
if (event.newState === 'open') {
// Hidden until positioned, otherwise it flashes at the popover default (screen centre).
popup.style.visibility = 'hidden';
}
});
popup.addEventListener('toggle', (event) => {
const open = event.newState === 'open';
trigger.setAttribute('aria-expanded', String(open));
if (!open) {
window.removeEventListener('scroll', position, true);
window.removeEventListener('resize', position);
setActive(-1);
if (search) {
search.value = '';
filter('');
setActive(-1);
}
return;
}
position();
popup.style.visibility = '';
// Start on the selected option, like a native select.
setActive(visible().findIndex((option) => option.getAttribute('aria-selected') === 'true'));
search?.focus();
// Capture phase so scrolling any ancestor also repositions it.
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
});
}
// For a select that stays put while the markup around it is swapped (a table's filters toolbar): takes the fresh
// copy's option details, each option's meta (e.g. a count) and whether it's disabled, and keeps what's chosen.
export function refreshOptions(select, fresh) {
const next = new Map([...fresh.querySelectorAll('[role="option"][data-value]')].map((option) => [option.dataset.value, option]));
select.querySelectorAll('[role="option"][data-value]').forEach((option) => {
const source = next.get(option.dataset.value);
if (!source) {
return;
}
option.dataset.meta = source.dataset.meta ?? '';
const meta = option.querySelector('[data-select-option-meta]');
if (meta) {
meta.textContent = source.dataset.meta ?? '';
}
if (source.hasAttribute('aria-disabled')) {
option.setAttribute('aria-disabled', 'true');
} else {
option.removeAttribute('aria-disabled');
}
});
const shown = select.querySelector('[data-select-meta]');
if (shown && select.dataset.multiple !== 'true') {
shown.textContent = select.querySelector('[role="option"][aria-selected="true"]')?.dataset.meta ?? '';
}
}
export function initSelects(scope = document) {
scope.querySelectorAll('[data-select]').forEach(initSelect);
}
initSelects();
// Selects added to the page later (a Livewire render or wire:navigate, fetched HTML) set themselves up.
new MutationObserver((records) => {
for (const node of records.flatMap((record) => [...record.addedNodes])) {
if (node instanceof Element) {
(node.matches('[data-select]') ? [node] : node.querySelectorAll('[data-select]')).forEach(initSelect);
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
app/View/Widget/ElementIds.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Container\Attributes\Scoped;
use LogicException;
/**
* Keeps element ids unique within one response, so labels, aria-describedby and #fragments
* always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
*/
#[Scoped]
final class ElementIds
{
/** @var array<string, true> */
private array $used = [];
/**
* Reserves an id for this response.
*
* A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
* id is one the caller chose and may reference from JS or CSS, so silently renaming it would
* break that reference; a duplicate throws instead, which surfaces in development and tests.
*/
public function claim(string $id, bool $explicit = false): string
{
if (! isset($this->used[$id])) {
return $this->reserve($id);
}
if ($explicit) {
throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
}
$suffix = 2;
while (isset($this->used["{$id}-{$suffix}"])) {
$suffix++;
}
return $this->reserve("{$id}-{$suffix}");
}
private function reserve(string $id): string
{
$this->used[$id] = true;
return $id;
}
}
app/View/Widget/FormField.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Contracts\Support\MessageBag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\ComponentAttributeBag;
/**
* Server-side state of one form widget: its dot-notation key, a valid id, its validation
* messages and its old input. Every <x-widget.input.*> and the date picker resolve through
* here, so array names (items[0][date]) and named error bags behave the same everywhere.
*/
final class FormField
{
/**
* @param list<string> $errors
*/
private function __construct(
public readonly ?string $name,
public readonly string $id,
public readonly ?string $key,
public readonly array $errors,
// The property a wire:model or x-model attribute binds it to, if any.
public readonly ?string $bound = null,
) {}
/**
* @param mixed $errorBag the view's shared $errors (absent outside a web request)
* @param string|array<int, string>|null $error an explicit message from the caller; overrides the bag
*/
public static function make(
?string $name,
?string $id,
mixed $errorBag,
string|array|null $error = null,
string $bag = 'default',
string $idPrefix = 'field',
?ComponentAttributeBag $attributes = null,
): self {
// With no name, a Livewire or Alpine binding (wire:model="email") names the field. Its errors are filed under
// that property, and its id stays the same on every render, which Livewire's morph needs to keep the element
// (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).
$bound = $attributes === null ? null : self::boundTo($attributes);
$key = match (true) {
$name !== null && $name !== '' => self::key($name),
$bound !== null => self::key($bound),
default => null,
};
$messages = match (true) {
$error !== null => Arr::wrap($error),
$key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),
default => [],
};
return new self(
$name,
app(ElementIds::class)->claim(
$id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),
explicit: $id !== null,
),
$key,
array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),
$bound,
);
}
/**
* The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule
* reports a bad second choice under tags.1, which a multiple select named tags must still show. Only
* numbered children count, so a field named address doesn't take errors meant for address[city].
*
* @return list<string>
*/
private static function messagesFor(MessageBag $bag, string $key): array
{
$items = array_filter(
$bag->getMessages(),
static fn (string $name): bool => preg_match('/^'.preg_quote($key, '/').'\.\d+$/', $name) === 1,
ARRAY_FILTER_USE_KEY,
);
return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));
}
/**
* items[0][date] → items.0.date and tags[] → tags: the key Laravel files errors and old input under.
*/
public static function key(string $name): string
{
return trim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $name), '.');
}
/**
* The id a field named $name gets when it's the first of that name on the page, for links to it
* (the error summary). A later duplicate gets a -2 suffix, which links can't know about.
*/
public static function idFor(string $name): string
{
return self::idFrom(self::key($name));
}
/**
* The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.
*/
public static function boundTo(ComponentAttributeBag $attributes): ?string
{
return array_values(self::binding($attributes))[0] ?? null;
}
private static function idFrom(string $key): string
{
return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');
}
public function hasError(): bool
{
return $this->errors !== [];
}
public function errorId(): string
{
return $this->id.'-error';
}
public function infoId(): string
{
return $this->id.'-info';
}
/**
* Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire
* property: a re-render then draws the field as it is, which Livewire morphs onto the page.
*/
public function old(mixed $default = null): mixed
{
if ($default === null) {
[$found, $live] = $this->fromLivewire();
$default = $found ? $live : null;
}
return $this->key === null ? $default : old($this->key, $default);
}
/**
* The bound property's value while Livewire renders the component that holds it: Livewire shares that component
* with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.
* $key reads inside it: a range bound to period reads period.start.
*
* @return array{0: bool, 1: mixed}
*/
public function fromLivewire(?string $key = null): array
{
$component = $this->bound === null ? null : view()->shared('__livewire');
return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : "{$this->bound}.{$key}")] : [false, null];
}
/**
* The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range
* picker binds period.start and period.end with the same modifiers. Empty without one.
*
* @return array<string, string>
*/
public static function binding(ComponentAttributeBag $attributes): array
{
foreach ($attributes->getAttributes() as $attribute => $value) {
if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {
return [$attribute => $value];
}
}
return [];
}
/**
* Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a
* failed submit "no old value" means unticked, but only when that submit was this box's own form. A page
* with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.
*
* @param bool $alwaysSent it has an unchecked-value, so its form always sends something under its name
* @param mixed $errorBag the view's shared $errors
*/
public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool
{
// Bound to a Livewire property: that says, true/false, or for a list of boxes, whether it holds this value.
[$found, $live] = $this->fromLivewire();
if ($found) {
return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;
}
if ($this->key === null || $disabled || ! session()->hasOldInput()) {
return $default;
}
$old = old($this->key);
if ($old !== null) {
return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);
}
// Nothing under this name, though this box always sends something: its form wasn't the one submitted.
if ($alwaysSent) {
return $default;
}
// A form with its own error bag: no errors in that bag means the failed submit was a different form.
if ($bag !== 'default') {
return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;
}
// One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.
return false;
}
/** Values cast to backed enums (Plan::Pro) compare as their backing value. */
private static function text(mixed $value): string
{
return (string) ($value instanceof \BackedEnum ? $value->value : $value);
}
/**
* What the caller passed, minus `class` (that styles the wrapper) and the aria attributes
* this widget manages itself. Goes on the element that is actually submitted.
*/
public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);
}
/**
* aria-invalid plus one aria-describedby that joins the error or the hint, and the caller's own
* ids. Two separate aria-describedby attributes would make the browser silently drop one.
*/
public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
// Not both: the frame hides the hint while there's an error, and aria-describedby reads hidden text, so a
// screen reader would hear two messages that often say the same thing. resources/js/field brings the hint
// back when the error clears.
$describedBy = array_filter([
$this->hasError() ? $this->errorId() : null,
$hasInfo && ! $this->hasError() ? $this->infoId() : null,
$attributes->get('aria-describedby'),
]);
return new ComponentAttributeBag([
'aria-invalid' => $this->hasError() ? 'true' : null,
'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
]);
}
/**
* For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
* hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
*/
public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
}
/**
* The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
* placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
*/
public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => ! self::isBinding($key)), $hasInfo);
}
private static function isBinding(string $key): bool
{
return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
}
/**
* forwarded() and aria() together, for widgets whose visible control is also the submitted one.
*/
public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
}
}