Select
Pick a value from a list — the native select, a custom listbox, a filterable combobox or a scrolling wheel.
Preview
Single
Multiple
<div class="w-full max-w-xs space-y-6">
<div>
<p class="mb-1.5 text-sm font-medium text-foreground">{{ __('Single') }}</p>
<x-ui.combobox name="framework" :placeholder="__('Select framework…')">
<x-ui.combobox.option value="laravel">Laravel</x-ui.combobox.option>
<x-ui.combobox.option value="rails">Ruby on Rails</x-ui.combobox.option>
<x-ui.combobox.option value="django">Django</x-ui.combobox.option>
<x-ui.combobox.option value="next">Next.js</x-ui.combobox.option>
<x-ui.combobox.option value="phoenix">Phoenix</x-ui.combobox.option>
</x-ui.combobox>
</div>
<div>
<p class="mb-1.5 text-sm font-medium text-foreground">{{ __('Multiple') }}</p>
<x-ui.combobox name="stacks" multiple :placeholder="__('Add frameworks…')">
<x-ui.combobox.option value="laravel">Laravel</x-ui.combobox.option>
<x-ui.combobox.option value="rails">Ruby on Rails</x-ui.combobox.option>
<x-ui.combobox.option value="django">Django</x-ui.combobox.option>
<x-ui.combobox.option value="next">Next.js</x-ui.combobox.option>
<x-ui.combobox.option value="phoenix">Phoenix</x-ui.combobox.option>
</x-ui.combobox>
</div>
</div>
Installation
php artisan ui:add combobox
Note
This component ships an Alpine behavior module at
resources/js/ui/combobox.js. Import it once from your bundle so it registers on alpine:init:
import './combobox.js';
Registry contract
php artisan ui:add combobox
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/combobox.blade.php -
resources/views/components/ui/combobox/option.blade.php -
resources/js/ui/combobox.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Combobox (`combobox`)
Autocomplete input with a filterable listbox, keyboard selection and a hidden form value.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add combobox
```
## Usage
```blade
<div class="w-full max-w-xs space-y-6">
<div>
<p class="mb-1.5 text-sm font-medium text-foreground">{{ __('Single') }}</p>
<x-ui.combobox name="framework" :placeholder="__('Select framework…')">
<x-ui.combobox.option value="laravel">Laravel</x-ui.combobox.option>
<x-ui.combobox.option value="rails">Ruby on Rails</x-ui.combobox.option>
<x-ui.combobox.option value="django">Django</x-ui.combobox.option>
<x-ui.combobox.option value="next">Next.js</x-ui.combobox.option>
<x-ui.combobox.option value="phoenix">Phoenix</x-ui.combobox.option>
</x-ui.combobox>
</div>
<div>
<p class="mb-1.5 text-sm font-medium text-foreground">{{ __('Multiple') }}</p>
<x-ui.combobox name="stacks" multiple :placeholder="__('Add frameworks…')">
<x-ui.combobox.option value="laravel">Laravel</x-ui.combobox.option>
<x-ui.combobox.option value="rails">Ruby on Rails</x-ui.combobox.option>
<x-ui.combobox.option value="django">Django</x-ui.combobox.option>
<x-ui.combobox.option value="next">Next.js</x-ui.combobox.option>
<x-ui.combobox.option value="phoenix">Phoenix</x-ui.combobox.option>
</x-ui.combobox>
</div>
</div>
```
## Props
- `name` (mixed|null, default `null`) — Form field name; inherited from a wrapping field via @aware or set directly. Mirrors the chosen value(s) into a hidden input.
- `value` (string, default ``) — Preselected value in single mode, or an array of values in multiple mode.
- `label` (string, default ``) — Visible label shown in the input for the current selection.
- `placeholder` (string|null, default `null`) — Placeholder text shown while nothing is typed or selected; null uses the translated "Select…".
- `multiple` (bool, default `false`) — Allows more than one selection, shown as removable chips, and keeps the list open after each pick.
- `freeText` (bool, default `false`) — Lets the typed query become a value when it matches no option, via an "Add …" row.
- `clearable` (bool, default `false`) — Shows a clear button that resets the field to empty.
- `inputId` (string|null, default `null`) — The id of the text input, so a <label for> names it. aria-label, aria-labelledby and aria-describedby on the tag also go to the input.
- `filter` (bool|null, default `null`) — Filter the options in the browser against the typed text. Null: on, except in server mode, where the host has filtered.
- `server` (bool, default `false`) — Server mode: typing dispatches combobox-search { query, sequence, done(), fail(message), isCurrent() } after debounce ms; until done() the list is busy (aria-busy, a loading row, a polite status).
- `debounce` (int, default `200`) — Server mode: milliseconds after the last keystroke before combobox-search is dispatched.
- `loadingText` (string|null, default `null`) — Server mode: loading row and status text; null uses the translated "Searching…".
- `errorText` (string|null, default `null`) — Server mode: text shown when the host calls fail() without a message.
- `actionsLabel` (string|null, default `null`) — Accessible name of the actions footer group. Null: the translated "Actions".
- `options` (array|null, default `null`) — Data options rendered as option rows before the slot: a list of ['value', 'label', 'description'?] rows (a row without a value is skipped) or a value => label map. The description shows muted under the label, truncated, inside the option. In server or remote mode they are the first list (answer 0), shown until the first search is sent.
- `valueExpr` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `labelExpr` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `sequenceExpr` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `sequence` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `action` (bool, default `false`) — Declared by @props in the registry Blade source.
## Use when
- Use when the option set is long enough that search, suggestion, or filtering speeds selection.
- Letting the user filter a long option list by typing before picking one.
- Collecting a value not necessarily in the option list, by pairing freeText with the option set.
- The options come from a server search as the user types; use server mode.
- Picking a record from a server search (an account, a subject, a collection) without writing search glue: point endpoint at a JSON route or wire-search at a Livewire method.
## Avoid when
- Do not use advanced selection UI when a simpler visible control would be clearer.
- Choose this when long lists or uncertain labels make search and autocomplete valuable.
- Choosing among a short, fixed list with no need to filter; use select-advanced or select instead.
- Free-text entry with no options to filter; use input instead.
## Anti-patterns
- Using advanced search for a tiny list
## Rules
- Use the `<brok:combobox>` tag (or `<x-ui.combobox>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/combobox
- Registry JSON (files, props, contract): https://brokui.dev/r/open/combobox.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Autocomplete
<div class="w-full max-w-xs space-y-6">
<div>
<p class="mb-1.5 text-sm font-medium text-foreground">{{ __('Autocomplete (free text)') }}</p>
<x-ui.combobox name="tag" free-text clearable :placeholder="__('Type or pick a tag…')">
<x-ui.combobox.option value="bug">Bug</x-ui.combobox.option>
<x-ui.combobox.option value="feature">Feature</x-ui.combobox.option>
<x-ui.combobox.option value="chore">Chore</x-ui.combobox.option>
</x-ui.combobox>
</div>
</div>
{{-- Footer rows: a "Create project" action in the actions slot is an option
the arrow keys reach and dispatches combobox-action; the footer slot is a
text row under the listbox, never an option. --}}
<div x-data="{ created: '' }" x-on:combobox-action="created = $event.detail.query || @js(__('Untitled'))" class="w-full max-w-xs space-y-2">
<x-ui.combobox name="project" :placeholder="__('Select project…')" :aria-label="__('Project')" :actions-label="__('Project actions')">
<x-ui.combobox.option value="apollo">Apollo</x-ui.combobox.option>
<x-ui.combobox.option value="gemini">Gemini</x-ui.combobox.option>
<x-ui.combobox.option value="mercury">Mercury</x-ui.combobox.option>
<x-slot:actions>
<x-ui.combobox.option value="create" action>{{ __('Create project') }}</x-ui.combobox.option>
</x-slot:actions>
<x-slot:footer>
<span class="inline-flex items-center gap-2"><x-ui.hotkeys.hint keys="enter" :decorative="false" /> {{ __('Choose') }}</span>
<span class="inline-flex items-center gap-2"><x-ui.hotkeys.hint keys="esc" :decorative="false" /> {{ __('Close') }}</span>
</x-slot:footer>
</x-ui.combobox>
<p class="text-sm text-muted-foreground">{{ __('Create:') }} <span data-slot="preview-event-log" class="font-mono text-xs" x-text="created === '' ? @js(__('nothing yet')) : created">{{ __('nothing yet') }}</span></p>
</div>
Preselected
{{-- `value` seeds the selection: a string in single mode, an array in multiple
mode. Labels resolve from the matching options, so old() repopulation after
a failed validation needs no extra wiring. --}}
<div class="w-full max-w-xs space-y-6">
<div>
<p class="mb-1.5 text-sm font-medium text-foreground">{{ __('Single, preselected') }}</p>
<x-ui.combobox name="framework" value="django" :placeholder="__('Select framework…')">
<x-ui.combobox.option value="laravel">Laravel</x-ui.combobox.option>
<x-ui.combobox.option value="rails">Ruby on Rails</x-ui.combobox.option>
<x-ui.combobox.option value="django">Django</x-ui.combobox.option>
</x-ui.combobox>
</div>
<div>
<p class="mb-1.5 text-sm font-medium text-foreground">{{ __('Multiple, preselected') }}</p>
<x-ui.combobox name="stacks" multiple :value="['next', 'laravel']" :placeholder="__('Add frameworks…')">
<x-ui.combobox.option value="laravel">Laravel</x-ui.combobox.option>
<x-ui.combobox.option value="rails">Ruby on Rails</x-ui.combobox.option>
<x-ui.combobox.option value="django">Django</x-ui.combobox.option>
<x-ui.combobox.option value="next">Next.js</x-ui.combobox.option>
</x-ui.combobox>
</div>
</div>
{{-- Remote search: `endpoint` makes the combobox fetch its own options. It
sends GET ?q=<query> after the debounce, stops the request before it
when you type on, and shows { value, label, description } hits from a
list or a { data: [...] } answer; a late, older answer never shows.
Opening the first field before typing asks for the empty query. Type
"fail" for the error row, "zzz" for the empty row.
The second field starts from data `options` (shown until the first
search answers) and names a preselected value that is not among them
through `labels`. With a name and options it also renders a native
<select> for browsers without JavaScript. In a Livewire component use
wire-search="searchAccounts" instead of an endpoint. --}}
<div
x-data="{ last: '' }"
x-on:change="$event.detail && $event.detail.value !== undefined && (last = JSON.stringify($event.detail))"
class="flex w-full max-w-sm flex-col gap-6"
>
<div class="flex flex-col gap-2">
<x-ui.label for="remote-account">{{ __('Account') }}</x-ui.label>
<x-ui.combobox
:endpoint="route('demo.combobox.accounts', [], false)"
input-id="remote-account"
name="account"
clearable
:placeholder="__('Search accounts')"
:empty-text="__('No accounts match.')"
/>
<p class="text-sm text-muted-foreground">
{{ __('Last change:') }}
<span data-slot="preview-event-log" class="font-mono text-xs text-foreground" x-text="last === '' ? '—' : last">—</span>
</p>
</div>
<div class="flex flex-col gap-2">
<x-ui.label for="remote-watchers">{{ __('Watchers') }}</x-ui.label>
<x-ui.combobox
:endpoint="route('demo.combobox.accounts', [], false)"
input-id="remote-watchers"
name="watchers"
multiple
:value="['acme', 'juniper']"
:labels="['juniper' => 'Juniper Legal']"
:options="[
['value' => 'acme', 'label' => 'Acme Corporation', 'description' => __('Customer, Amsterdam')],
['value' => 'atlas', 'label' => 'Atlas Logistics', 'description' => __('Supplier, Rotterdam')],
['value' => 'bluebird', 'label' => 'Bluebird Studios', 'description' => __('Customer, Berlin')],
]"
:placeholder="__('Add watchers…')"
/>
</div>
</div>
{{-- Server mode: typing dispatches `combobox-search` after the debounce; the
host filters and answers. This preview answers from a fake server that
takes 700 ms for a query of one or two characters and 150 ms for a longer
one, so a late answer for an older query is ignored (isCurrent()). The
options carry the sequence number of their answer. The input is named by
its <label for> through input-id. Type "fail" to see the error row. --}}
<div
x-data="{
tasks: [
{ id: 12, title: @js(__('Atlas launch checklist')) },
{ id: 15, title: @js(__('Atlas pricing page')) },
{ id: 21, title: @js(__('Audit log misses role changes')) },
{ id: 24, title: @js(__('Invite email lands in spam')) },
{ id: 30, title: @js(__('Backfill invoice numbers')) },
],
answer: { sequence: 0, hits: [] },
chosen: '',
timers: [],
init() { this.answer.hits = this.tasks.slice(0, 3) },
search(detail) {
const query = detail.query.trim().toLowerCase();
const delay = query.length <= 2 ? 700 : 150;
this.timers.push(setTimeout(() => {
if (query.includes('fail')) {
detail.fail();
return;
}
if (! detail.isCurrent()) return;
this.answer = { sequence: detail.sequence, hits: this.tasks.filter((task) => task.title.toLowerCase().includes(query)).slice(0, 3) };
detail.done();
}, delay));
},
destroy() { this.timers.forEach(clearTimeout) },
}"
class="flex w-full max-w-sm flex-col gap-2"
>
<x-ui.label for="server-task">{{ __('Task') }}</x-ui.label>
<x-ui.combobox
server
input-id="server-task"
name="task"
:placeholder="__('Type to search tasks')"
clearable
x-model="chosen"
x-on:combobox-search="search($event.detail)"
>
<template x-for="task in answer.hits" :key="answer.sequence + '-' + task.id">
<x-ui.combobox.option value-expr="task.id" label-expr="task.title" sequence-expr="answer.sequence">
<span x-text="task.title"></span>
</x-ui.combobox.option>
</template>
</x-ui.combobox>
<p class="text-sm text-muted-foreground">
{{ __('Chosen:') }}
<span data-slot="preview-event-log" class="font-mono text-foreground" x-text="chosen === '' ? '—' : chosen">—</span>
</p>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | mixed | null | null | Form field name; inherited from a wrapping field via @aware or set directly. Mirrors the chosen value(s) into a hidden input. |
| value | string | Preselected value in single mode, or an array of values in multiple mode. | |
| label | string | Visible label shown in the input for the current selection. | |
| placeholder | string | null | null | Placeholder text shown while nothing is typed or selected; null uses the translated "Select…". |
| multiple | bool | false | Allows more than one selection, shown as removable chips, and keeps the list open after each pick. |
| freeText | bool | false | Lets the typed query become a value when it matches no option, via an "Add …" row. |
| clearable | bool | false | Shows a clear button that resets the field to empty. |
| inputId | string | null | null | The id of the text input, so a <label for> names it. aria-label, aria-labelledby and aria-describedby on the tag also go to the input. |
| filter | bool | null | null | Filter the options in the browser against the typed text. Null: on, except in server mode, where the host has filtered. |
| server | bool | false | Server mode: typing dispatches combobox-search { query, sequence, done(), fail(message), isCurrent() } after debounce ms; until done() the list is busy (aria-busy, a loading row, a polite status). |
| debounce | int | 200 | Server mode: milliseconds after the last keystroke before combobox-search is dispatched. |
| loadingText | string | null | null | Server mode: loading row and status text; null uses the translated "Searching…". |
| errorText | string | null | null | Server mode: text shown when the host calls fail() without a message. |
| actionsLabel | string | null | null | Accessible name of the actions footer group. Null: the translated "Actions". |
| options | array | null | null | Data options rendered as option rows before the slot: a list of ['value', 'label', 'description'?] rows (a row without a value is skipped) or a value => label map. The description shows muted under the label, truncated, inside the option. In server or remote mode they are the first list (answer 0), shown until the first search is sent. |
| valueExpr | mixed | null | null | Declared by @props in the registry Blade source. |
| labelExpr | mixed | null | null | Declared by @props in the registry Blade source. |
| sequenceExpr | mixed | null | null | Declared by @props in the registry Blade source. |
| sequence | mixed | null | null | Declared by @props in the registry Blade source. |
| action | bool | false | Declared by @props in the registry Blade source. |
Slots
default— The <x-ui.combobox.option> list rendered inside the listbox.actions— Footer actions: combobox.option rows with action ("Create …"), in a named group that stays at the bottom of the listbox while it scrolls. They are options, so the arrow keys and Enter reach them.footer— A text row under the listbox, outside it (key hints, a note); it is never an option. Its attributes merge onto the row. With a footer the popup is a wrapper (data-slot=combobox-popup) around the listbox and the row.x-ui.combobox.option— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Typing filters the listbox; ArrowDown/Up move the active option, Enter selects it, Escape closes without selecting.
- Clicking outside the control or pressing Escape closes the listbox and returns focus to the input.
- In multiple mode each pick stays open and adds a removable chip; in single mode a pick closes the list.
- The first value and label are read from data attributes, so the x-data expression stays the same when a Livewire render changes them and the component is not started again; later values arrive through x-modelable (wire:model, x-model).
- An option leaves the keyboard model when its element leaves the page, and an option a render moved keeps one entry; while the list is open the chosen option (or the first one) is highlighted again.
- Server mode: typing takes a new sequence number at once, so earlier sequenced options hide and leave the keyboard model; a late, older answer stays hidden; fail(message) shows an error row.
- Footer: the actions slot holds actionable options (combobox.option action) in a sticky group at the end of the listbox; Enter or a click dispatches combobox-action { value, query } and never changes the value. The footer slot holds text only, outside the listbox. Without either the markup is unchanged.
- Remote mode (endpoint or wire-search): typing waits for the debounce, then the component asks the endpoint or the Livewire method itself and renders the hits as combobox.option rows numbered with their answer, so the arrow keys, Enter, aria-activedescendant, the loading row, the error row, the empty row and the polite status all work as in server mode; an older answer never shows.
- Remote mode: opening the list before any search, with no data options, searches for the empty query (the host decides what an empty query returns). With data options those show first, until the first query is sent.
- The change event detail carries labels: { value, label } in single mode and { values, items: [{ value, label }] } in multiple mode (value and values are unchanged).
- No-JS fallback (where possible): with a name and data options, a <noscript> native select (name, or name[] with multiple) lists the options and the chosen values (also values only labels names), selected; the enhanced field is hidden with x-cloak until Alpine starts (the page needs the usual [x-cloak] { display: none } rule, which Livewire adds), the single-mode hidden input is named only by Alpine, and the input-id moves to the select. Without data options there is no fallback.
- Binds wire:model (Livewire) or x-model (Alpine) to the value (an array in multiple mode) through x-modelable.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Find and select within a long or uncertain option set.
Use when
- Use when the option set is long enough that search, suggestion, or filtering speeds selection.
- Letting the user filter a long option list by typing before picking one.
- Collecting a value not necessarily in the option list, by pairing freeText with the option set.
- The options come from a server search as the user types; use server mode.
- Picking a record from a server search (an account, a subject, a collection) without writing search glue: point endpoint at a JSON route or wire-search at a Livewire method.
Avoid when
- Do not use advanced selection UI when a simpler visible control would be clearer.
- Choose this when long lists or uncertain labels make search and autocomplete valuable.
- Choosing among a short, fixed list with no need to filter; use select-advanced or select instead.
- Free-text entry with no options to filter; use input instead.
Use instead
- Visible choices for small sets
- Dedicated filter panel for complex refinement
Anti-patterns
- Using advanced search for a tiny list
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter Escape
- Focus
managed
- The input carries role="combobox" with aria-expanded and aria-activedescendant so a screen reader tracks the active option.
- Invalid state and the field's error message stay wired through aria-invalid and aria-describedby when the component inherits a wrapping field's name.
- Pass input-id with a <label for>, or aria-label / aria-labelledby on the tag; both reach the combobox input.
- Server mode: the listbox reports aria-busy while an answer is on its way, and a polite status outside the listbox announces Searching…, the result count or the failure.
- Remote hits and data options are combobox.option rows: role=option inside the listbox, with the description inside the option so it is read with the label. The no-JS select takes the input-id (so the <label for> names it), aria-label, aria-labelledby, aria-invalid and aria-describedby.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="combobox-{{ $record->id }}">
<div class="w-full max-w-xs space-y-6">
<div>
<p class="mb-1.5 text-sm font-medium text-foreground">{{ __('Single') }}</p>
<x-ui.combobox name="framework" :placeholder="__('Select framework…')">
<x-ui.combobox.option value="laravel">Laravel</x-ui.combobox.option>
<x-ui.combobox.option value="rails">Ruby on Rails</x-ui.combobox.option>
<x-ui.combobox.option value="django">Django</x-ui.combobox.option>
<x-ui.combobox.option value="next">Next.js</x-ui.combobox.option>
<x-ui.combobox.option value="phoenix">Phoenix</x-ui.combobox.option>
</x-ui.combobox>
</div>
<div>
<p class="mb-1.5 text-sm font-medium text-foreground">{{ __('Multiple') }}</p>
<x-ui.combobox name="stacks" multiple :placeholder="__('Add frameworks…')">
<x-ui.combobox.option value="laravel">Laravel</x-ui.combobox.option>
<x-ui.combobox.option value="rails">Ruby on Rails</x-ui.combobox.option>
<x-ui.combobox.option value="django">Django</x-ui.combobox.option>
<x-ui.combobox.option value="next">Next.js</x-ui.combobox.option>
<x-ui.combobox.option value="phoenix">Phoenix</x-ui.combobox.option>
</x-ui.combobox>
</div>
</div>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:combobox
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware so this
composite self-wires old()/aria-invalid/aria-describedby off the shared
$errors bag. Inert with no field ancestor and no own `name` —
fully backward-compatible. --}}
@aware([
'name' => null,
])
@props([
'name' => null,
'value' => '',
'label' => '',
// Null: the translated default "Select…".
'placeholder' => null,
'multiple' => false,
'freeText' => false,
'clearable' => false,
// The id of the text input, so a <label for="…"> names it. aria-label,
// aria-labelledby and aria-describedby on the tag also go to the input.
'inputId' => null,
// Filter the options in the browser against the typed text. Null: on,
// except in server mode, where the host has filtered.
'filter' => null,
// Server mode: typing dispatches `combobox-search` { query, sequence,
// done(), fail(message), isCurrent() } after `debounce` ms; until done()
// the list is busy (aria-busy, a loading row and a polite status).
// Options with `sequence` show only for the latest query.
'server' => false,
'debounce' => 200,
// Loading row and status text while an answer is on its way.
'loadingText' => null,
// Row shown when the host calls fail() without a message.
'errorText' => null,
// Name of the `actions` footer group. Null: the translated "Actions".
'actionsLabel' => null,
// Data options, rendered before the slot: a list of ['value', 'label',
// 'description'?] rows or a value => label map. In server mode they are
// the first list, shown until the first answer arrives.
'options' => null,
// Remote search (implies server mode): the component answers its own
// searches. `endpoint` is a URL fetched with GET and the query in
// `searchParam`; `wireSearch` is a method on the enclosing Livewire
// component, called with the query (give it #[Renderless]). Both answer
// a list of { value, label, description? } or { data: [...] }.
'endpoint' => null,
'searchParam' => 'q',
'wireSearch' => null,
// value => label for chosen values that are not among the rendered
// options (remote mode), so chips and the input show a name.
'labels' => null,
// Text of the empty row. Null: the translated "No results found.".
'emptyText' => null,
])
@php
$awareName = $name ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
// old() repopulates the chosen value(s) after a failed redirect-back. Multiple
// mode takes an array (`value` prop or old(`name[]`)); single mode a string.
$resolvedValue = filled($fieldName) ? old($fieldName, $value) : $value;
$resolvedValue = $multiple
? array_values(array_filter(array_map(fn ($v) => (string) $v, (array) $resolvedValue), 'filled'))
: (string) (is_array($resolvedValue) ? ($resolvedValue[0] ?? '') : $resolvedValue);
// aria-describedby points at the field's error region only when inheriting
// from a field AND there is an error to announce; a describedby on the tag
// is kept beside it.
$describedBy = trim(implode(' ', array_filter([
(string) $attributes->get('aria-describedby', ''),
(filled($awareName) && $hasBagError) ? $fieldName.'-error' : '',
]))) ?: null;
$placeholder ??= __('Select…');
$emptyText ??= __('No results found.');
// Data options: a list of rows or a value => label map. A row without a
// value is skipped.
$optionRows = [];
foreach ((array) ($options ?? []) as $optionKey => $option) {
if (is_array($option)) {
if (! array_key_exists('value', $option) || ! is_scalar($option['value'])) {
continue;
}
$optionRows[] = [
'value' => (string) $option['value'],
'label' => (string) ($option['label'] ?? $option['value']),
'description' => filled($option['description'] ?? null) ? (string) $option['description'] : null,
];
} elseif (is_scalar($option)) {
$optionRows[] = ['value' => (string) $optionKey, 'label' => (string) $option, 'description' => null];
}
}
// Remote search: a Livewire method (a plain identifier only, else every
// search fails) wins over an endpoint.
$remote = null;
if (filled($wireSearch)) {
$remote = ['wire' => preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', (string) $wireSearch) === 1 ? (string) $wireSearch : ''];
} elseif (filled($endpoint)) {
$remote = ['endpoint' => (string) $endpoint, 'param' => filled($searchParam) ? (string) $searchParam : 'q'];
}
$server = $server || $remote !== null;
$labelMap = [];
foreach ((array) ($labels ?? []) as $labelValue => $labelText) {
if (is_scalar($labelText) && filled($labelText)) {
$labelMap[(string) $labelValue] = (string) $labelText;
}
}
// Single mode: a preselected value takes its name from `labels`.
if (! $multiple && blank($label) && $resolvedValue !== '' && isset($labelMap[$resolvedValue])) {
$label = $labelMap[$resolvedValue];
}
// No-JS fallback: a native select of the data options, submitted under
// the same name; the enhanced control stays hidden until Alpine starts.
$nativeFallback = filled($fieldName) && $optionRows !== [];
// A chosen value that only `labels` names joins the select, so it still submits.
$nativeRows = $optionRows;
if ($nativeFallback) {
$listed = array_column($optionRows, 'value');
foreach ($multiple ? $resolvedValue : array_filter([$resolvedValue], 'filled') as $chosen) {
if (! in_array($chosen, $listed, true) && isset($labelMap[$chosen])) {
$nativeRows[] = ['value' => $chosen, 'label' => $labelMap[$chosen], 'description' => null];
}
}
}
$filter = $filter ?? ! $server;
$inputAria = $attributes->only(['aria-label', 'aria-labelledby']);
// A footer (key hints, a note) sits under the listbox, outside it, so the
// popup becomes a wrapper around the two; without one the listbox is the popup.
$hasFooter = isset($footer) && ! $footer->isEmpty();
$config = [
'multiple' => (bool) $multiple,
'freeText' => (bool) $freeText,
'clearable' => (bool) $clearable,
'filter' => (bool) $filter,
];
if ($server) {
$config += [
'server' => true,
'debounce' => max(0, (int) $debounce),
'texts' => [
'loading' => $loadingText ?? __('Searching…'),
'error' => $errorText ?? __('The search failed. Try again.'),
'empty' => $emptyText,
'one' => __('1 result'),
'many' => __(':count results'),
],
];
if ($remote !== null) {
$config['remote'] = $remote;
}
if ($optionRows !== []) {
$config['preloaded'] = true;
}
}
@endphp
{{--
Combobox (spec §17 combobox keyboard behavior + §19).
Editable text input that filters a listbox of options. ArrowDown opens and
moves, Enter selects the active option, Escape closes. In single mode the
chosen value mirrors to a hidden input; in `multiple` mode selections show as
removable chips and submit as `name[]` hidden inputs (the list stays open).
`value` preselects: a string in single mode, an array of values in multiple
mode; chip labels resolve from the matching options.
--}}
<div
{{-- The first value and label sit in data attributes, so the x-data
expression stays the same when a Livewire render changes them (a
changed x-data would start the component again with no options). --}}
x-data="uiCombobox(@js($config))"
data-value="{{ json_encode($resolvedValue) }}"
data-label="{{ $label }}"
@if ($labelMap !== []) data-labels="{{ json_encode($labelMap) }}" @endif
{{-- Modelable: `x-model` (Alpine) and `wire:model` (Livewire binds through
the same x-model contract) attach to the root. Inert without either. --}}
x-modelable="model"
x-id="['combobox-list']"
data-slot="combobox"
data-multiple="{{ $multiple ? 'true' : 'false' }}"
@if ($hasBagError) data-invalid="true" @endif
@if ($server) data-server x-bind:data-busy="busy ? 'true' : 'false'" @endif
@keydown.escape="if (open) { $event.stopPropagation(); close() }"
@click.outside="close()"
{{ $attributes->except(['name', 'aria-label', 'aria-labelledby', 'aria-describedby'])->merge(['class' => 'relative w-full']) }}
>
@if (filled($fieldName))
@if ($multiple)
<template x-for="s in selected" :key="s.value">
<input type="hidden" name="{{ $fieldName }}[]" :value="s.value" />
</template>
@elseif ($nativeFallback)
{{-- Named by Alpine, so without JavaScript only the native select submits. --}}
<input type="hidden" x-bind:name="@js($fieldName)" :value="value" />
@else
<input type="hidden" name="{{ $fieldName }}" :value="value" />
@endif
@endif
@if ($nativeFallback)
<noscript>
<select
data-slot="combobox-native"
name="{{ $fieldName }}{{ $multiple ? '[]' : '' }}"
@if ($multiple) multiple @endif
@if (filled($inputId)) id="{{ $inputId }}" @endif
{{ $inputAria }}
@if ($hasBagError) aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
class="w-full min-w-0 rounded-md border border-input bg-background px-control-x-md text-sm text-foreground {{ $multiple ? 'py-2' : 'h-control-h-md' }}"
>
@unless ($multiple)
<option value="">{{ $placeholder }}</option>
@endunless
@foreach ($nativeRows as $row)
<option value="{{ $row['value'] }}" @selected($multiple ? in_array($row['value'], $resolvedValue, true) : $row['value'] === $resolvedValue)>{{ $row['label'] }}</option>
@endforeach
</select>
</noscript>
@endif
{{-- Selected chips (multiple mode). --}}
<div x-show="multiple && selected.length" x-cloak class="mb-2 flex flex-wrap gap-1">
<template x-for="s in selected" :key="s.value">
<span class="inline-flex items-center gap-1 rounded-sm bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
<span x-text="s.label"></span>
<button type="button" @click="removeChip(s.value)" x-bind:aria-label="@js(__('Remove')) + ' ' + s.label" class="text-secondary-foreground/70 transition-colors hover:text-secondary-foreground motion-reduce:transition-none">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</span>
</template>
</div>
<div class="relative" @if ($nativeFallback) x-cloak @endif>
<input
type="text"
data-slot="combobox-input"
{{-- With the native fallback the select holds the id until Alpine starts. --}}
@if (filled($inputId) && $nativeFallback) x-bind:id="@js($inputId)" @elseif (filled($inputId)) id="{{ $inputId }}" @endif
{{ $inputAria }}
role="combobox"
x-ref="input"
aria-autocomplete="list"
:aria-expanded="open"
x-bind:aria-controls="$id('combobox-list')"
x-bind:aria-activedescendant="activeOptionId"
@if ($hasBagError) aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
x-model="query"
@focus="show()"
@input="onInput()"
@keydown.down.prevent="open ? move(1) : show()"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="choose()"
placeholder="{{ $placeholder }}"
autocomplete="off"
class="h-control-h-md w-full min-w-0 rounded-md border border-input bg-background px-control-x-md {{ $clearable ? 'pe-16' : 'pe-9' }} text-sm text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
/>
@if ($clearable)
<button
type="button"
data-slot="combobox-clear"
tabindex="-1"
x-show="hasClearable"
x-cloak
@click="clear()"
aria-label="{{ __('Clear') }}"
class="absolute end-9 top-0 inline-flex h-control-h-md w-7 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground motion-reduce:transition-none"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
@endif
<button
type="button"
data-slot="combobox-trigger"
tabindex="-1"
@click="toggle(); $refs.input.focus()"
class="absolute end-0 top-0 inline-flex h-control-h-md w-9 cursor-pointer items-center justify-center text-muted-foreground"
>
<span class="sr-only">{{ __('Toggle options') }}</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
@if ($hasFooter)
<div
data-slot="combobox-popup"
x-show="open"
x-cloak
x-transition:enter="motion-safe:transition motion-safe:duration-100 motion-safe:ease-out"
x-transition:enter-start="opacity-0 motion-safe:scale-[0.97]"
x-transition:enter-end="opacity-100 motion-safe:scale-100"
x-transition:leave="motion-safe:transition motion-safe:duration-75 motion-safe:ease-in"
x-transition:leave-start="opacity-100 motion-safe:scale-100"
x-transition:leave-end="opacity-0 motion-safe:scale-[0.97]"
class="absolute z-dropdown mt-1 w-full origin-top overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md motion-reduce:transition-none"
>
<div
data-slot="combobox-list"
role="listbox"
x-ref="list"
x-bind:id="$id('combobox-list')"
x-bind:aria-multiselectable="multiple ? 'true' : 'false'"
@if ($server) x-bind:aria-busy="busy ? 'true' : 'false'" @endif
class="max-h-60 overflow-y-auto p-1"
>
@else
<div
data-slot="combobox-list"
role="listbox"
x-ref="list"
x-bind:id="$id('combobox-list')"
x-bind:aria-multiselectable="multiple ? 'true' : 'false'"
@if ($server) x-bind:aria-busy="busy ? 'true' : 'false'" @endif
x-show="open"
x-cloak
x-transition:enter="motion-safe:transition motion-safe:duration-100 motion-safe:ease-out"
x-transition:enter-start="opacity-0 motion-safe:scale-[0.97]"
x-transition:enter-end="opacity-100 motion-safe:scale-100"
x-transition:leave="motion-safe:transition motion-safe:duration-75 motion-safe:ease-in"
x-transition:leave-start="opacity-100 motion-safe:scale-100"
x-transition:leave-end="opacity-0 motion-safe:scale-[0.97]"
class="absolute z-dropdown mt-1 max-h-60 w-full origin-top overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md motion-reduce:transition-none"
>
@endif
@foreach ($optionRows as $row)
<x-ui.combobox.option :value="$row['value']" :label="$row['label']" :sequence="$server ? 0 : null">
@if ($row['description'] !== null)
<span class="flex min-w-0 flex-col">
<span data-slot="combobox-option-label" class="truncate">{{ $row['label'] }}</span>
<span data-slot="combobox-option-description" class="truncate text-xs text-muted-foreground">{{ $row['description'] }}</span>
</span>
@else
{{ $row['label'] }}
@endif
</x-ui.combobox.option>
@endforeach
@if ($remote !== null)
{{-- Remote answers: one row per hit, numbered with its answer. --}}
<template x-for="hit in remoteHits.items" :key="remoteHits.sequence + ':' + hit.value">
<x-ui.combobox.option value-expr="hit.value" label-expr="hit.label" sequence-expr="remoteHits.sequence">
<span class="flex min-w-0 flex-col">
<span data-slot="combobox-option-label" class="truncate" x-text="hit.label"></span>
<span data-slot="combobox-option-description" class="truncate text-xs text-muted-foreground" x-show="hit.description" x-text="hit.description"></span>
</span>
</x-ui.combobox.option>
</template>
@endif
{{ $slot }}
@if ($freeText)
<div
data-slot="combobox-create"
role="option"
x-show="showCreateRow"
x-cloak
@click="commitFreeText()"
class="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-sm text-popover-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground motion-reduce:transition-none"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 text-muted-foreground">
<path d="M5 12h14M12 5v14" />
</svg>
<span><span class="text-muted-foreground">{{ __('Add') }}</span> <span class="font-medium" x-text="'‘' + query.trim() + '’'"></span></span>
</div>
@endif
@if ($server)
<div data-slot="combobox-loading" role="presentation" x-show="busy" x-cloak class="flex items-center gap-2 px-2 py-2 text-sm text-muted-foreground">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4 shrink-0 motion-safe:animate-spin">
<path d="M21 12a9 9 0 1 1-6.2-8.56" />
</svg>
<span x-text="texts.loading"></span>
</div>
<div data-slot="combobox-error" role="presentation" x-show="!busy && error !== ''" x-cloak class="px-2 py-6 text-center text-sm text-muted-foreground" x-text="error"></div>
@endif
<div data-slot="combobox-empty" x-show="!hasResults && !showCreateRow && !busy && error === ''" class="py-6 text-center text-sm text-muted-foreground">{{ $emptyText }}</div>
@isset($actions)
{{-- Footer actions ("Create …"): combobox.option rows with `action`
in a named group that stays at the bottom while the list
scrolls. They are options, so the arrow keys and Enter reach
them; choosing one dispatches combobox-action. --}}
<div data-slot="combobox-actions" role="group" aria-label="{{ filled($actionsLabel) ? $actionsLabel : __('Actions') }}" class="sticky bottom-0 mt-1 border-t border-border bg-popover pt-1">
{{ $actions }}
</div>
@endisset
</div>
@if ($hasFooter)
{{-- Not an option: text only (key hints). Put a choice in `actions`. --}}
<div data-slot="combobox-footer" {{ $footer->attributes->merge(['class' => 'flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1 border-t border-border px-3 py-2 text-xs text-muted-foreground']) }}>{{ $footer }}</div>
</div>
@endif
@if ($server)
{{-- Outside the listbox: announces loading, the result count and a failure. --}}
<div data-slot="combobox-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only" x-text="statusText"></div>
@endif
</div>
@props([
'value' => null,
'label' => null,
// Client data: Alpine expressions for the value and the label, for an
// option rendered inside <template x-for> (value-expr="task.id",
// label-expr="task.title"). They replace value and label.
'valueExpr' => null,
'labelExpr' => null,
// Client data: an Alpine expression for the sequence number of the
// answer this option came from (sequence-expr="answer.sequence").
'sequenceExpr' => null,
// Server mode: the sequence number of the answer this option belongs to
// (from the combobox-search event). It shows and takes keys only while
// that number is the latest, so a late, older answer never shows.
'sequence' => null,
// A footer action ("Create …") in the combobox `actions` slot: always
// shown, reached by the arrow keys, never chosen. Choosing it closes the
// list and dispatches combobox-action { value, query }.
'action' => false,
])
@php
$dynamic = filled($valueExpr);
$text = $label ?? html_entity_decode(trim(strip_tags((string) $slot)), ENT_QUOTES);
// One expression per use, so client data and server strings share the markup.
$valueJs = $dynamic ? '('.$valueExpr.')' : \Illuminate\Support\Js::from($value)->toHtml();
$labelJs = $dynamic ? '('.(filled($labelExpr) ? $labelExpr : $valueExpr).')' : \Illuminate\Support\Js::from($text)->toHtml();
@endphp
<div
data-slot="combobox-option"
role="option"
@if ($dynamic)
x-bind:data-value="{{ $valueJs }}"
@else
data-value="{{ $value }}"
@endif
@if (filled($sequenceExpr) || $sequence !== null) data-sequenced @endif
@if ($action) data-action @endif
x-data="{ optionId: null }"
x-init="optionId = registerOption($el, {{ $valueJs }}, {{ $labelJs }})"
x-bind:id="optionId"
x-show="optionShown({{ $labelJs }}, $el)"
@if ($action) aria-selected="false" @else :aria-selected="isChosen({{ $valueJs }})" @endif
:data-active="activeOptionId === optionId"
@if ($action) @click="runAction({{ $valueJs }})" @else @click="select({{ $valueJs }}, {{ $labelJs }})" @endif
@mousemove="activeOptionId = optionId"
{{ $attributes->merge(['class' => 'flex cursor-pointer items-center justify-between gap-2 rounded-sm px-2 py-2 text-sm text-popover-foreground outline-none transition-colors data-[active=true]:bg-accent data-[active=true]:text-accent-foreground']) }}
>
@if (filled($sequenceExpr) || $sequence !== null)
{{-- The answer's number, on a child without x-show, so a render updates it. --}}
<span data-slot="combobox-sequence" hidden @if (filled($sequenceExpr)) x-bind:data-sequence="{{ $sequenceExpr }}" @else data-sequence="{{ (int) $sequence }}" @endif></span>
@endif
<span class="min-w-0 truncate">{{ $slot }}</span>
@unless ($action)
<svg x-show="isChosen({{ $valueJs }})" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-primary">
<path d="M20 6 9 17l-5-5" />
</svg>
@endunless
</div>
/**
* Combobox behavior (spec §17 combobox keyboard behavior + §19).
*
* Options register themselves with the root so filtering/navigation are fully
* reactive. The text input doubles as the filter (`query`); selecting an option
* stores its value (mirrored to a hidden input for forms) and shows its label.
*
* The first value and label come from data attributes, not from the x-data
* expression: a Livewire render that changed a value printed in x-data made
* Alpine start the component again with no options. The expression now stays
* the same across renders; later values arrive through x-modelable.
*
* An option leaves the state when its element leaves the page (a Livewire
* render, an x-if), and an option element that registers again (a render
* moved it) keeps one entry, so the arrow keys and Enter only reach listed
* options.
*
* Server mode (`server`): typing dispatches `combobox-search` { query,
* sequence, done(), fail(message), isCurrent() } after `debounce` ms, the
* list is busy until done(), and the browser filter is off (the host
* filtered). An option with `sequence` shows only for the latest query.
*
* Remote mode (`remote`: { endpoint, param } or { wire }): the component
* answers its own searches instead of dispatching `combobox-search`. It
* fetches the endpoint with GET (the query in `param`, the previous request
* aborted) or calls the Livewire method with $wire.call(method, query), and
* renders the hits ({ value, label, description? }, as a list or under
* `data`) in its own <template x-for>, numbered with their answer. Opening
* the list before any search, with no data options, searches for ''.
*
* Labels: a cache of value -> label (the `data-labels` attribute, every hit,
* every option and every choice) names a value that arrives through
* x-modelable or a preselection when its option is not rendered.
*/
const WIRE_METHOD = /^[A-Za-z_][A-Za-z0-9_]*$/;
/** Milliseconds before a Livewire search that never answered shows the error row. */
const REMOTE_TIMEOUT = 15000;
/**
* The hits of a remote answer: a list, or a list under `data` (a Laravel
* resource collection). Null for any other shape; malformed rows are skipped
* and a repeated value keeps its first row.
*/
function normalizeHits(payload) {
const rows = Array.isArray(payload) ? payload : (Array.isArray(payload?.data) ? payload.data : null);
if (rows === null) return null;
const seen = new Set();
const hits = [];
rows.forEach((row) => {
if (row === null || typeof row !== 'object') return;
const { value } = row;
if (!(typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)))) return;
if (seen.has(String(value))) return;
seen.add(String(value));
const label = typeof row.label === 'string' || typeof row.label === 'number' ? String(row.label) : String(value);
const description = typeof row.description === 'string' && row.description !== '' ? row.description : null;
hits.push({ value, label, description });
});
return hits;
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiCombobox', (config = {}) => ({
open: false,
multiple: config.multiple ?? false,
freeText: config.freeText ?? false,
clearable: config.clearable ?? false,
query: '',
value: '',
selectedLabel: '',
selected: [], // [{ value, label }] — multiple mode only
// Preselected values (multiple mode). Chips are seeded from these as the
// options register, so their labels come from the option markup.
initial: [],
// Filter options in the browser. Off in server mode by default.
filter: config.filter ?? !(config.server ?? false),
server: config.server ?? false,
debounce: config.debounce ?? 200,
texts: config.texts ?? {},
sequence: 0,
revision: 0,
remote: config.remote ?? null,
preloaded: config.preloaded ?? false,
// The latest remote answer; its rows carry its sequence number.
remoteHits: { sequence: 0, items: [] },
abortController: null,
labelCache: {},
busy: false,
error: '',
searchTimer: null,
sequenceObserver: null,
movedSinceQuery: false,
activeOptionId: null,
options: [],
// Option ids come from this counter, never from the value: a value with
// spaces or quotes made an invalid id (aria-activedescendant pointed
// nowhere) and duplicate values shared one. Navigation uses the id too.
optionSequence: 0,
/** Read the first value and label from the root's data attributes. */
init() {
let value = config.value ?? '';
try {
if (this.$el.dataset.value !== undefined) value = JSON.parse(this.$el.dataset.value);
} catch {
value = this.$el.dataset.value;
}
const label = this.$el.dataset.label ?? config.label ?? '';
this.readLabels();
if (this.server) this.observeSequences();
if (this.multiple) {
this.initial = Array.isArray(value) ? value.map(String) : [];
// Chips named by `labels` show at once, in value order.
this.selected = this.initial
.filter((v) => v in this.labelCache)
.map((v) => ({ value: v, label: this.labelCache[v] }));
// Remote mode: a preselected value that neither `labels` nor a
// rendered option names keeps its raw value as the chip label,
// so it still submits (client mode keeps dropping unknown values).
if (this.remote) {
this.$nextTick(() => {
const missing = this.initial.filter((v) => !this.isChosen(v));
if (missing.length === 0) return;
this.selected = this.initial
.filter((v) => this.isChosen(v) || missing.includes(v))
.map((v) => this.selected.find((s) => String(s.value) === v) ?? { value: v, label: this.knownLabel(v) ?? v });
});
}
} else {
this.value = value == null ? '' : String(value);
this.selectedLabel = String(label);
this.query = String(label);
}
},
destroy() {
clearTimeout(this.searchTimer);
this.sequenceObserver?.disconnect();
this.abortController?.abort();
this.abortController = null;
},
/** Merge the `data-labels` map (value -> label) into the label cache. */
readLabels() {
const raw = this.$el?.dataset?.labels;
if (!raw) return;
try {
const labels = JSON.parse(raw);
if (labels && typeof labels === 'object' && !Array.isArray(labels)) {
Object.entries(labels).forEach(([value, label]) => this.rememberLabel(value, label));
}
} catch {
// A malformed map names nothing.
}
},
rememberLabel(value, label) {
if (value === undefined || value === null || label === undefined || label === null || String(label) === '') return;
this.labelCache[String(value)] = String(label);
},
/** The known label of a value, or null. */
knownLabel(value) {
const key = String(value);
return Object.prototype.hasOwnProperty.call(this.labelCache, key) ? this.labelCache[key] : null;
},
/**
* Server mode: a render can change the sequence marker of an element
* it keeps (after done() ran), so check the rows again when one changes.
*/
observeSequences() {
if (typeof MutationObserver === 'undefined') return;
this.sequenceObserver = new MutationObserver(() => { this.revision++; });
this.sequenceObserver.observe(this.$el, { subtree: true, attributes: true, attributeFilter: ['data-sequence'] });
},
/**
* Server mode: take a new number at once (earlier answers turn stale),
* then ask the host after the debounce.
*/
queueSearch(text = this.query) {
if (!this.server) return;
clearTimeout(this.searchTimer);
// A newer query makes the request on its way stale: stop it.
this.abortController?.abort();
this.abortController = null;
const sequence = ++this.sequence;
const query = text;
this.busy = true;
this.error = '';
this.movedSinceQuery = false;
this.searchTimer = setTimeout(() => {
if (sequence !== this.sequence) return;
if (this.remote) {
this.searchRemote(String(query ?? ''), sequence);
return;
}
this.$dispatch('combobox-search', {
query: String(query ?? ''),
sequence,
done: () => this.settle(sequence),
fail: (message) => this.settle(sequence, message ?? this.texts.error ?? ''),
isCurrent: () => sequence === this.sequence,
});
}, Math.max(0, Number(this.debounce) || 0));
},
/** The endpoint URL for a query, the query merged into its own query string. */
searchUrl(query) {
const url = new URL(String(this.remote.endpoint), window.location.href);
url.searchParams.set(this.remote.param || 'q', query);
return url.toString();
},
/** The enclosing Livewire component's $wire, or null outside one. */
wire() {
try {
return this.$wire || null;
} catch {
return null;
}
},
/** Ask the endpoint or the Livewire method; show the answer if still current. */
async searchRemote(query, sequence) {
let payload;
try {
if (this.remote.wire !== undefined) {
const method = String(this.remote.wire ?? '');
const wire = this.wire();
if (!WIRE_METHOD.test(method) || !wire) {
throw new Error('No Livewire method to search with.');
}
// Livewire 3 and 4 define $call (call is its alias). A request
// that Livewire answers with its own error page may never
// settle the promise, so the loading row gives up after a
// time limit and shows the error text.
let timer = null;
const limit = new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error('The search took too long.')), Number(this.remote.timeout) > 0 ? Number(this.remote.timeout) : REMOTE_TIMEOUT);
});
try {
payload = await Promise.race([
typeof wire.$call === 'function' ? wire.$call(method, query) : wire.call(method, query),
limit,
]);
} finally {
clearTimeout(timer);
}
} else {
const controller = new AbortController();
this.abortController = controller;
const url = this.searchUrl(query);
// X-Requested-With only for the same origin: on a cross-origin
// endpoint it would force a CORS preflight.
const headers = new URL(url).origin === window.location.origin
? { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
: { Accept: 'application/json' };
const response = await fetch(url, {
method: 'GET',
headers,
credentials: 'same-origin',
signal: controller.signal,
});
if (this.abortController === controller) this.abortController = null;
if (!response.ok) throw new Error(`Search answered ${response.status}.`);
payload = await response.json();
}
} catch (error) {
if (error?.name === 'AbortError') return;
this.settle(sequence, this.texts.error ?? '');
return;
}
if (sequence !== this.sequence) return;
const hits = normalizeHits(payload);
if (hits === null) {
this.settle(sequence, this.texts.error ?? '');
return;
}
hits.forEach((hit) => this.rememberLabel(hit.value, hit.label));
this.remoteHits = { sequence, items: hits };
this.settle(sequence);
},
normalizeHits,
settle(sequence, error = null) {
if (sequence !== this.sequence) return;
this.busy = false;
this.error = error === null ? '' : String(error || this.texts.error || '');
this.$nextTick(() => {
if (sequence !== this.sequence) return;
this.revision++;
this.prune();
if (this.open && (!this.movedSinceQuery || !this.visible().some((option) => option.id === this.activeOptionId))) {
this.highlightDefault();
}
});
},
/** True outside any sequenced option, or for the latest number. */
isCurrent(el) {
// Read the revision too: settle() bumps it after the answer's
// render, so rows whose data-sequence changed without a new query
// number (client data, a reused element) are checked again.
void this.sequence;
void this.revision;
const holder = el?.closest?.('[data-sequenced]');
if (!holder) return true;
// The number sits on a hidden marker child: Alpine's morph leaves
// the attributes of an element alone when its x-show state
// changes, so a number on the shown element itself would not update.
const marker = holder.querySelector(':scope > [data-sequence]');
return Number(marker?.dataset.sequence) === this.sequence;
},
get statusText() {
if (!this.server || !this.open) return '';
if (this.busy) return this.texts.loading ?? '';
if (this.error !== '') return this.error;
const count = this.visible().length;
if (count === 0) return this.texts.empty ?? '';
if (count === 1) return this.texts.one ?? '';
return String(this.texts.many ?? '').replace(':count', String(count));
},
/** Drop entries whose element is gone. */
prune() {
if (this.options.some((option) => !option.element.isConnected)) {
this.options = this.options.filter((option) => option.element.isConnected);
}
},
/** Highlight the chosen option when listed, else the first one. */
highlightDefault() {
const options = this.visible();
this.activeOptionId = options.find((option) => String(option.value) === String(this.value))?.id
?? options[0]?.id
?? null;
},
/**
* Modelable value (`x-modelable="model"` on the root): the chosen value,
* or the array of chosen values in multiple mode. An inbound value
* (Livewire, `x-model`) rebuilds the chips / the input text from the
* registered options; an unknown value keeps its raw text as the label.
*/
get model() {
return this.multiple ? this.selected.map((s) => s.value) : this.value;
},
set model(v) {
if (this.multiple) {
const values = Array.isArray(v) ? v.map(String) : (v == null || v === '' ? [] : [String(v)]);
if (JSON.stringify(values) === JSON.stringify(this.selected.map((s) => String(s.value)))) return;
this.initial = values;
this.readLabels();
this.selected = values.map((value) => {
const option = this.options.find((o) => String(o.value) === value);
return option ? { value: option.value, label: option.label } : { value, label: this.knownLabel(value) ?? value };
});
return;
}
const next = v == null ? '' : String(v);
if (next === String(this.value)) return;
this.value = next;
this.readLabels();
const option = this.options.find((o) => String(o.value) === next && !o.element.hasAttribute('data-action'));
this.selectedLabel = option ? option.label : (this.knownLabel(next) ?? (this.freeText ? next : ''));
this.query = this.selectedLabel;
},
isChosen(v) {
// Compare as text: a Livewire value "7" and an option value 7 are one choice.
return this.multiple
? this.selected.some((s) => String(s.value) === String(v))
: String(this.value) === String(v);
},
removeChip(v) {
this.selected = this.selected.filter((s) => String(s.value) !== String(v));
this.emitChange();
},
/**
* The `change` event: { value, label } in single mode, { values,
* items: [{ value, label }] } in multiple mode.
*/
emitChange() {
if (this.multiple) {
this.$dispatch('change', {
values: this.selected.map((s) => s.value),
items: this.selected.map((s) => ({ value: s.value, label: s.label })),
});
return;
}
this.$dispatch('change', { value: this.value, label: this.value === '' ? '' : this.selectedLabel });
},
/**
* A footer action: close the list, keep focus in the field and hand
* the value and the typed text to the host. The value is not chosen.
*/
runAction(value) {
const query = this.query.trim();
this.close();
this.$dispatch('combobox-action', { value, query });
this.$nextTick(() => this.$refs.input?.focus());
},
registerOption(element, value, label) {
// An element that registers again (a render moved it) keeps its
// entry and id; a new element gets a new id.
const existing = this.options.findIndex((option) => option.element === element);
const id = existing === -1
? `${this.$id('combobox-list')}-option-${++this.optionSequence}`
: this.options[existing].id;
const entry = { value, label: String(label), id, element };
if (!element.hasAttribute?.('data-action')) this.rememberLabel(value, label);
if (existing === -1) {
this.options.push(entry);
if (typeof window.Alpine?.onElRemoved === 'function') {
window.Alpine.onElRemoved(element, () => this.unregisterOption(element));
}
} else {
this.options.splice(existing, 1, entry);
}
if (this.multiple) {
// Seed a preselected chip, keeping the order the values were given in.
if (this.initial.includes(String(value)) && !this.isChosen(value)) {
this.selected.push({ value, label: String(label) });
this.selected.sort((a, b) => this.initial.indexOf(String(a.value)) - this.initial.indexOf(String(b.value)));
}
return id;
}
// A preselected single value without an explicit `label` shows the
// matching option's label instead of an empty input.
if (this.value !== '' && this.selectedLabel === '' && String(value) === String(this.value)) {
this.selectedLabel = String(label);
this.query = String(label);
}
return id;
},
/** Forget an option whose element left the page. */
unregisterOption(element) {
const index = this.options.findIndex((option) => option.element === element);
if (index === -1) return;
const [removed] = this.options.splice(index, 1);
if (removed.id !== this.activeOptionId) return;
if (this.open) this.highlightDefault();
else this.activeOptionId = null;
},
/**
* With `el`: a stale sequenced option never shows; a current one skips
* the filter. A footer action (`action`) shows whatever the query.
*/
optionShown(text, el) {
if (el?.hasAttribute?.('data-action')) return true;
if (el?.closest?.('[data-sequenced]')) return this.isCurrent(el);
return this.matches(text);
},
matches(text) {
if (!this.filter) {
return true;
}
// While showing a fresh single selection, do not filter everything away.
if (this.query.trim() === '' || (!this.multiple && this.query === this.selectedLabel)) {
return true;
}
return String(text).toLowerCase().includes(this.query.trim().toLowerCase());
},
visible() {
return this.options.filter((o) => o.element.isConnected && this.optionShown(o.label, o.element));
},
/** True with a matching option; a footer action does not count. */
get hasResults() {
return this.visible().some((option) => !option.element.hasAttribute('data-action'));
},
get showCreateRow() {
const q = this.query.trim();
return this.freeText
&& q !== ''
&& !this.options.some((o) => String(o.label).toLowerCase() === q.toLowerCase());
},
get hasClearable() {
return this.clearable
&& (this.query.trim() !== '' || this.value !== '' || this.selected.length > 0);
},
show() {
this.open = true;
// Remote mode: the first open with nothing listed asks for ''.
if (this.remote && this.sequence === 0 && !this.preloaded) {
this.queueSearch('');
}
this.prune();
if (!this.visible().some((option) => option.id === this.activeOptionId)) {
this.highlightDefault();
}
},
/** Typing: open, search in server mode, and highlight the first match. */
onInput() {
if (this.server) {
this.queueSearch();
}
this.show();
},
close() {
this.open = false;
this.query = this.multiple ? '' : this.selectedLabel;
},
toggle() {
this.open ? this.close() : this.show();
},
move(direction) {
this.movedSinceQuery = true;
if (!this.open) {
this.show();
return;
}
const options = this.visible();
if (options.length === 0) {
this.activeOptionId = null;
return;
}
const current = options.findIndex((option) => option.id === this.activeOptionId);
const next = current === -1
? (direction > 0 ? 0 : options.length - 1)
: (current + direction + options.length) % options.length;
this.activeOptionId = options[next].id;
this.scrollActiveIntoView();
},
scrollActiveIntoView() {
this.$nextTick(() => {
this.options
.find((option) => option.id === this.activeOptionId)
?.element.scrollIntoView({ block: 'nearest' });
});
},
choose() {
// With a highlighted option, commit it as before.
const activeOption = this.visible().find((option) => option.id === this.activeOptionId);
if (this.open && activeOption) {
activeOption.element.click();
return;
}
// Otherwise, in free-text mode, commit the raw typed query.
if (this.freeText && this.query.trim() !== '') {
this.commitFreeText();
}
},
commitFreeText() {
const text = this.query.trim();
if (text === '') {
return;
}
// Prefer an exact existing option (case-insensitive) over a raw value.
const existing = this.options.find((o) => String(o.label).toLowerCase() === text.toLowerCase());
if (existing) {
this.select(existing.value, existing.label);
return;
}
this.select(text, text);
},
clear() {
this.query = '';
if (this.server) this.queueSearch();
this.value = '';
this.selectedLabel = '';
this.selected = [];
this.activeOptionId = null;
this.emitChange();
this.$nextTick(() => this.$refs.input?.focus());
},
select(value, label) {
if (this.multiple) {
// Toggle the value in/out of the selection and keep the list open.
const i = this.selected.findIndex((s) => String(s.value) === String(value));
if (i === -1) {
this.selected.push({ value, label });
} else {
this.selected.splice(i, 1);
}
this.query = '';
this.rememberLabel(value, label);
this.emitChange();
this.$nextTick(() => this.$refs.input?.focus());
return;
}
this.value = value;
this.query = label;
this.selectedLabel = label;
this.open = false;
this.rememberLabel(value, label);
this.emitChange();
this.$nextTick(() => this.$refs.input?.focus());
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.8.0 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0