COMBOBOX: Filter-as-you-type single-select (ARIA 1.2)
COMPOSES: .form-control | .dropdown-menu | .dropdown-item
Typing filters the list; arrows move the active option; Enter selects; Escape restores the committed value. Free text that matches no option rolls back on close — the field can only ever hold a real option (error prevention, C05).
Basic: label + visible input + hidden value field. The hidden
input (data-combobox-target="value") is the form field; the visible
input carries only the display text.
<div data-controller="combobox"><div class="combobox"><input class="form-control" role="combobox" …><ul class="dropdown-menu" role="listbox">…</ul></div></div>
Async mode: set data-combobox-url-value and the
controller debounces input, then fetches {url}?q={query} and renders
the JSON {value, text}[] response as options — same commit, keyboard,
and rollback contract as the static list above. Below
data-combobox-min-chars-value a .combobox-hint row prompts
for more input; while a request is pending a .combobox-loading row
(.spinner-border-sm) shows; a failed fetch reuses
.combobox-empty with retry copy. A persistent visually-hidden
data-combobox-target="status" span announces each transition
("Type at least N characters…", "Searching…", "N results available", "No results
found", or the error copy) to screen readers — recommended over
aria-live on the visually-toggled rows themselves, since a live-region
node that gets hidden toggled on/off is an unreliable announcement
trigger.
Preview mock: these examples have no real backend, so each carries
an inline <script type="application/json" data-combobox-target="fixture">
sibling (a static {results, delay, error} fixture). When present, the
controller reads it instead of calling fetch — same visible delay and
superseded-request behavior, though the mechanics differ slightly (the fixture
cancels its timer on abort rather than rejecting like a real aborted
fetch). Production usage simply omits the fixture and points
url at a real same-origin endpoint.
Try: 1 letter (hint) → 2+ letters of a school name (loading → results) →
zzz (no results).
This example's fixture always simulates a failed request — type any letter to see the loading row, then the retry-copy error state.
<div data-controller="combobox" data-combobox-url-value="/api/schools/search" data-combobox-min-chars-value="2" data-combobox-debounce-value="250">…<span class="tw:sr-only" aria-live="polite" data-combobox-target="status"></span>…<li class="combobox-hint" hidden data-combobox-target="hint">…<li class="combobox-loading" hidden data-combobox-target="loading"><span class="spinner-border spinner-border-sm"></span>…</li></div>
Preselected: set the visible input's value, the hidden
input's value, and .active on the matching option.