Skip to content
Brok UI

Loading…

No results

Select

Open source Core

Pick a value from a list — the native select, a custom listbox, a filterable combobox or a scrolling wheel.

Version
v1.8.0
Stability
stable
License
MIT
Related
Native Select
Advanced Select
Wheel Picker

Combobox — Autocomplete input with a filterable listbox, keyboard selection and a hidden form value.

Preview

Single

Laravel
Ruby on Rails
Django
Next.js
Phoenix
No results found.

Multiple

Laravel
Ruby on Rails
Django
Next.js
Phoenix
No results found.
previews.components.combobox.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/combobox.blade.php
  • blade resources/views/components/ui/combobox/option.blade.php
  • js resources/js/ui/combobox.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

combobox.md
# 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.blade.php Blade
<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>
preselected.blade.php Blade
{{-- `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.blade.php Blade
{{-- 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.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

combobox combobox-actions combobox-clear combobox-create combobox-empty combobox-error combobox-footer combobox-input combobox-list combobox-loading combobox-native combobox-option combobox-option-description combobox-option-label combobox-popup combobox-sequence combobox-status combobox-trigger

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

Searchable selection and filtering

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
root combobox-input combobox-clear combobox-trigger combobox-list combobox-create combobox-option combobox-actions combobox-popup combobox-footer combobox-option-label combobox-option-description combobox-native
Theming hooks
combobox combobox-actions combobox-clear combobox-create combobox-empty combobox-error combobox-footer combobox-input combobox-list combobox-loading combobox-native combobox-option combobox-option-description combobox-option-label combobox-popup combobox-sequence combobox-status combobox-trigger

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/combobox.blade.php Blade
{{-- 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>
resources/views/components/ui/combobox/option.blade.php Blade
@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>
resources/js/ui/combobox.js JS
/**
 * 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