<ui-combobox>ui-combobox.mjs · Full test suite
APG combobox-with-list-autocomplete
pattern. One tag with four sub-variants selected via mode="none|list|inline|both"
(the host attribute is mode, not autocomplete, so it can't collide with
the HTML autocomplete autofill token on the inner input).
The consumer passes an async source function; the component owns the popover, ARIA,
keyboard nav, the ghost-text mirror, and Google-style arrow-nav preview (arrow keys swap the input
value to the highlighted item; wrap past either end restores the typed query).
The chips / multiple attributes add the tag-input pattern
(Open UI "Tag", Material "input chips"): committed values render as removable chips and the typed
text is only ever a query — see §11–12.
In its single-line modes the field draws at the same height as a text <input>
beside it, so a form column keeps one rhythm. The component sets line-height: normal
on the inner input and its ghost-text mirror to reach that height; the contract is on
Form fields § Field height. Chip rows wrap and grow past
it by design.
| Attribute | Values | Default | Purpose |
|---|---|---|---|
mode | none · list · inline · both | list | APG sub-variant. Distinct from the inner input's HTML autocomplete attribute (browser autofill token) — set that on the child input separately. |
name | string | — | Form field name (participates via hidden input). Omit if your child <input> carries its own name. |
value | string | "" | Initial value |
min-chars | int | 0 (2 for inline) | Skip source call below N chars |
debounce-ms | int | 150 | Debounce before calling source |
disabled | boolean | — | Disables input |
chips | boolean | — |
Single-selection chips mode. The selected item appears as one removable chip. Only listed items can be selected; typing a new query replaces the chip. Supports mode="list" and "none". With inline or both, it uses list and logs a warning because inline text completion does not support chip selection (§11). Set this attribute before connection; changing it while chips are present is unsupported.
|
multiple | boolean | — |
Enables chips with multiple selections. Selected items are hidden from the option list and return when their chips are removed. With min-chars="0", the list stays open after each selection.
|
raw | boolean | — |
Remove the field's border, padding and radius so a wrapper such as .input-group can provide them. The dropdown retains its border and shadow.
|
anchor | element id | — | Anchor and size the dropdown to the element with this id (typically the wrapping .input-group) instead of to the combobox itself. |
| Property | Type | Purpose |
|---|---|---|
.source | async ({query, req_id, signal}) => items[] | Required. Return items, or throw to signal an error. On abort, reject with new DOMException('aborted', 'AbortError'). Promises are memoized per query, so keep source() pure — external UI state belongs in pipeline-event listeners. |
.render_item | (item, query) => string | Node | Custom option body. Strings are inserted as textContent (escaped); return a Node for rich markup. |
.format | (item) => string | Canonical string used for input value on commit and for the inline ghost suffix. |
.to_query | (input) => string | Typed text → the canonical query handed to source() and used as the memo key. Default is identity; override for diacritic normalization, alias substitution, or language-specific casing. Dispatched events still carry the literal typed text, so consumers see what the user typed. |
.parse | (item) => any | Each item returned by source() passes through this before format, render_item, and the selection see it. Default is identity; override to adapt a server shape ({key} vs {id} vs a scalar) into the one the other callbacks expect. |
.option_disabled | (item) => boolean |
Return true to display the item as disabled, with aria-disabled="true". It cannot be highlighted, selected or previewed by the inline suggestion. Keep it visible when render_item explains why it is unavailable; filter it out in source() to hide it. This callback runs on every render, so availability can change.
|
.selected | Item | null | First committed item (null = free-form / nothing selected). |
.selected_items | Item[] | All committed item objects (0–1 entries outside multiple). |
.values | string[] | Committed labels — format(item) per selection entry. |
.value | string | Current input value. Chips modes: the committed label (first) or "" — the typed text is a query, not a value. Setting "" clears the selection silently; a non-empty string becomes a single string-item chip (form-restore path, no source round-trip). |
.open | boolean | Whether listbox is open. |
.empty_label | string | Text shown when source() returns []. Default "No matches". |
.chip_remove_label | (label) => string | Chip remove-button accessible name. Default `Remove ${label}` — override for localization (like empty_label). |
.chip_announcement | (action, label) => string | Polite live-region text on chip add/remove. Default `${label} added|removed` — override for localization. |
| Event | Detail | Fires |
|---|---|---|
input | — | Whenever the visible value changes due to user action: typing, arrow-nav, wrap/Escape restore, ghost-accept, commit, .clear(). Read event.target.value. Programmatic .value sets from outside the component do not fire. |
change | {value, item, source, values, action?} | On commit (click, Enter, Tab, Right, End, clear). Suppressed when value+item are unchanged. When commit changes the visible value, input fires first, then change. Chips modes add action: 'add' | 'remove' | 'clear'; on remove, item is the removed item and value/values are the post-removal state. |
ui-combobox-highlight | {item, opt_id, value} | Low-level hook: fires on every arrow-nav or hover highlight change (including wrap back to typed — item/opt_id are null). Use when you need to react to virtual-focus changes that don't alter the input value (e.g. mode="list" or "none", mouse hover). For most consumers, input + change is enough. |
ui-combobox-open | {} | Listbox opened. |
ui-combobox-close | {reason} | Listbox closed. |
ui-combobox-fetch | {query, req_id, from_memo} | A fetch is starting. Fires for memoized queries too (from_memo: true), so status indicators aren't stuck on stale state when source()'s body is skipped. |
ui-combobox-response | {query, req_id, items, from_memo} | Results settled and rendered. The component has already dropped stale responses, so listeners always see the latest. |
ui-combobox-error | {query, req_id, error} | Source rejected with a non-AbortError. Aborts are swallowed silently. |
ui-combobox-revert | {reason, query} | Search was abandoned without a response: input dropped below min-chars (reason: "min-chars") or .clear() was called (reason: "clear"). Use to reset external status UI to idle. |
| Method | Signature | Purpose |
|---|---|---|
open_list() | () → void | Programmatically open the listbox (fires a source call if needed). No-op when already open, disabled, or in mode="inline" — that mode has no list. |
close() | () → void | Programmatically close the listbox. Fires ui-combobox-close with reason: "programmatic". |
clear() | () → void | Reset value, selection (including all chips), and ghost; close the list. Fires input, ui-combobox-revert, and change (chips modes: action: 'clear'). |
select(item) | (any) → boolean | Chips modes: commit an item programmatically through the same flow as clicking its option. false when already selected. (Named select/deselect because Element.prototype.remove is taken.) |
deselect(item_or_label) | (any) → boolean | Remove a chip by item object or label; fires change with action: 'remove'. false when nothing matched. |
focus(options?) | (FocusOptions?) → void | Forward focus to the inner <input>. Safe to call before connect (no-op). |
revert() | () → void | Drop tentative state (arrow-nav baseline swap and inline ghost) without committing or clearing. For Cancel buttons and route changes that want to settle the UI without firing a change. |
commit_value(v) | (string) → void | User-driven equivalent of el.value = v: assigns the value AND triggers the same change/refetch path the input-event would. Use when a programmatic caller wants consumers (search wiring, revert listeners) to react. |
| CSS custom property | Default | Description |
|---|---|---|
--_input-bg | var(--surface-1, #fff) | Input field background color. |
--_input-ink | var(--text-1, #111) | Input field text color. |
--_input-border | var(--border-color, #ccc) | Input field border color. |
--_ghost-ink | var(--text-2, #777) | Inline ghost-suffix text color. |
--_list-bg | var(--surface-overlay, #fff) | Dropdown listbox background color. The list floats in the top layer, so it takes the overlay surface rather than a step on the surface ramp — see Elevation § Overlay surfaces. |
--_list-border | var(--border-color, #ccc) | Dropdown listbox border color. |
--_list-max-height | 16rem | Maximum height of the dropdown listbox before it scrolls. |
--_list-border-width | var(--border-size-1, 1px) | Dropdown listbox border width. |
--_list-radius | var(--radius-2, 4px) | Dropdown listbox border radius. |
--_option-hover-bg | var(--surface-3, #eee) |
Background for hovered and highlighted options (aria-selected="true"). Pointer and keyboard navigation use the same color.
|
--_option-selected-bg | var(--surface-3, #eee) | Option background when selected (committed). |
--_option-mark-bg | transparent | Background of a <mark> inside an option — the query-match highlight a consumer's render_item can emit. Marks are bolded; the tint is off by default. |
--_option-mark-ink | inherit | Text color of that <mark>. |
--_option-pad-y | var(--size-2, 0.5em) |
Vertical padding for option, empty, error and loading rows. This is independent of --_field-pad-y, which chips mode sets to zero for the input. Dropdown rows still need their padding.
|
--_option-pad-x | var(--size-2, 0.5em) | Horizontal padding of those same rows. Square inset by default, matching the field. |
--_field-pad-y | var(--size-2, 0.5em) | Vertical padding for both the input and the ghost mirror (kept in sync). |
--_field-pad-x | var(--size-2, 0.5em) | Horizontal padding shorthand; overridden by --_field-pad-left and --_field-pad-right. Same token as the vertical padding — a field's inset is square (Forms § Anatomy). |
--_field-pad-left | var(--_field-pad-x) | Left padding for the input and mirror — set independently when a prefix decoration (e.g. a flag) needs extra room. |
--_field-pad-right | var(--_field-pad-x) | Right padding for the input and mirror — set independently when a suffix decoration needs extra room. |
--_field-border-width | var(--border-size-1, 1px) | Input field border width (also applied to the invisible mirror border so box models stay in sync). |
--_field-radius | var(--radius-2, 4px) | Input field border radius. |
--_chip-bg | var(--surface-3, #eee) | Chip background (chips modes). |
--_chip-ink | var(--text-1, #111) | Chip text color. |
--_chip-border | transparent | Chip border color — set for an outline-style chip. |
The dropdown can grow wider than its field. It uses min-width: anchor-size(width), allowing rich options (§8) to determine a larger width. To keep it within a dialog or narrow panel, set [role="listbox"] { max-width: anchor-size(width) }. This limits the list to the field width so option rows can truncate overflowing text.
The component reflects two state attributes on its host. Use them in CSS, but do not set them yourself. data-loading="true" appears after a source() call has been pending for 300 ms; the default styling dims existing options. Faster responses show no loading state. data-ghost="active" indicates a visible inline suggestion. It does not change the typed characters or casing; committing applies format(item).
Consumers updating external state (status badges, suffix icons) should listen
to the pipeline events above rather than writing state inside source(). The component
memoizes results per query, so source()'s body does not re-run on a repeat
query — writes placed there would leave the indicator stuck.
See §9 below for a worked example.
Supply your own <input> as a child to control native input
semantics: type, autocomplete (browser autofill token), name,
required, placeholder, pattern, maxlength,
minlength, mobile hints like inputmode. The combobox owns the ARIA contract
(role, aria-expanded, aria-controls,
aria-autocomplete, aria-activedescendant) and the listbox popover.
If you don't supply an input, the component creates a plain <input type="text"
autocomplete="off">; that's fine for app-only pickers (country, role) but not for fields
the browser can autofill like email or username — see §6.
Each demo's Show code contains the complete HTML, CSS, and JS for that demo — copy and paste works as-is. The demo area is populated by cloning the template at the top of each section.
mode="list" — filtered listboxThe default. Typing filters a listbox of suggestions. Free-form values are allowed on Enter.
mode="inline" — ghost suffix onlyNo listbox. The top match is shown as a gray suffix after the caret; Tab or Right accepts it.
mode="both" — listbox + ghost + arrow-nav previewThe full APG pattern. Type to filter; arrow keys preview each match in the input (Google-style); wrap past the last/first item restores your typed query. Enter/Tab commits.
mode="none" — editable selectAlways shows the complete, unfiltered list. Use this for a free-text field with suggested values. Focusing the field opens the list without typing.
Shorter queries deliberately take longer in this demo, simulating a broad search such as s taking longer than spock. Normal typing can therefore produce responses out of order. This exercises stale-response checks, AbortSignal cancellation and the rule that existing results remain visible until replacements arrive. The host's data-loading="true" attribute dims existing options and can be used for additional loading styles.
render_item — email autocomplete (with browser autofill)
Each option shows a name above an email address, as used by the workflow invite form. The child <input type="email" autocomplete="email"> retains browser email autofill while the combobox supplies team-member suggestions. The combobox suppresses its empty-state row when there is no query, reducing interference with the browser's autofill popup.
Type a query containing "fail" to see the error row. The next successful query clears it.
Consumer-controlled decoration: the listbox renders each option as flag + name + dialing code
via .render_item, and a flag prefix inside the group tracks both arrow-nav preview
and committed selection. The wrapper is the shared
.input-group pattern, and the
raw attribute strips the combobox's own chrome so the group owns the border and
focus ring.
Two hooks are wired:
input — fires on every user-driven value change: keystrokes and arrow-nav through options (which rewrites the input value in mode="both"). Read event.target.value and preview the flag for the current text.change fires when a value is committed through Enter, Tab, a click, ArrowRight acceptance or clearing. Use it to update decorations for the committed selection.
Related: the Open UI customizable select rich-content example and <x-select> show the same pattern for a closed select.
Async source with a live-region suffix that shows a spinner while loading and a
result-state icon afterwards. The group drives state via
group.dataset.state, which the shared
.input-group state styles pick up
(flux → warning tint while loading; the .suffix.state slot recolors
to match). The combobox carries raw so the group owns the border.
Keep source() limited to returning items or throwing an error. Update the suffix in listeners for ui-combobox-fetch, ui-combobox-response, ui-combobox-error and ui-combobox-revert. These events also cover cached queries and track the latest request. Each handler checks cb.value so a late response cannot restore an indicator after the input was cleared. An input listener handles an empty field immediately; clear() emits ui-combobox-revert itself.
The name attribute creates a hidden field; the value is sent with the form on submit.
Submitted value:
chips — single-token closed-list picker
Only values from the list are valid: the committed member becomes a removable chip and the typed
text is only ever a query. The chip is one unit — Backspace in the empty input removes
it whole, typing with it present replaces it, and its × (or Delete on the focused chip)
removes it. Free-form Enter commits nothing. An author-supplied required
is mirrored onto the selection, so native form validation blocks until a member is picked.
Chips do not support inline text completion. With mode="inline" or "both", the component uses list and logs a warning. Inline completion commits text, while a chip selects an item that must be removed or replaced as a whole. Supporting both would require a design for item selection and restoration of the typed query; changing the attribute alone cannot provide it.
multiple — tag input
Any number of chips, each removable. With min-chars="0" the list stays open after
each pick and already-selected items leave it (they resurface when removed). One hidden field
per chip, so the form submits like a checkbox group — read with
FormData.getAll(name). ← at the start of the input steps into the chip
row; ←/→ traverse it.
Submitted values:
The ghost suffix previews what Tab, →, End or Enter
at the end of the input would commit. It comes from an item whose format(item)
starts with the typed text, compared without regard to case, so lowercase typing still matches
title-case items. Diacritics still count: typed te does not match Téa.
After arrow navigation only the highlighted item can supply the suggestion; showing another
item's suffix would contradict the highlight. Otherwise the first rendered item that still
matches supplies it, not only the first item. For example, typed Lov keeps
Lovech suggested from the previous results while the new query is still loading.
Disabled items never supply it, because none of those keys can commit them.
While the user types, the input keeps exactly what they typed, in their own casing; the ghost
adds the rest of the canonical text. Typed PAR against Paris therefore
reads PARis until the commit writes format(item).
The first ↓ or ↑ saves the typed text. Each option the arrow keys highlight
then replaces the input value with format(item), so the user sees what
Enter would commit. The component fires input on the host for each
preview but runs no new search. Moving past either end of the list, Esc, or leaving
the field without committing restores the saved text. A run of disabled options at an end wraps
the same way, because nothing further can be reached in that direction.
A highlight from the pointer, or the automatic highlight of the option matching the input when the list reopens, only marks the option. It saves nothing and changes nothing in the input, so Esc then just closes the list. The next arrow key moves on from that option and starts the preview.
Each typed query passes through to_query, and the promise source()
returns is memoized under that canonical query, for up to 50 queries with the oldest dropped
first. A request that is not memoized aborts every older request still running. A response
older than the one already rendered is dropped, and so is an older error. A rejected promise is
removed from the memo, aborts included, so the same query calls source() again next
time instead of staying in the loading state. An AbortError is otherwise silent;
any other error renders an error row and is announced through the component's live region,
because the listbox itself is not a live region.
A request opens the list immediately, so a slow network still gives feedback at once; existing
options stay in place. data-loading="true" and, in an otherwise empty list, a
… row appear only after 300 ms, so fast responses cause no flash. Focusing the input
with min-chars="0" prefetches, so the list has content the first time it opens.
An empty query with no results closes the list rather than showing the empty row: "No matches"
for nothing typed means nothing, and on an autocomplete="email" field it would cover
the browser's autofill popup.
role="combobox", aria-autocomplete, aria-expanded, aria-controls, aria-activedescendant.<ul role="listbox"> carrying popover="manual", labelled by the input; options are <li role="option">.role="option" aria-disabled="true" so they appear in the accessibility tree and are announced. They are not keyboard-navigable — the component skips elements without data-opt-id. For richer announcements (spinner, result count, error icon) use the pipeline events with a consumer-owned aria-live region — see §9 (Suffix search indicator).option_disabled has aria-disabled="true" and keeps its data-opt-id, which indexes the rendered item list. Removing that id would misalign later options with their items. The highlight check prevents navigation to it. It remains visible to assistive technology; include the reason in render_item so the option's accessible name explains why it is unavailable.
aria-activedescendant (APG virtual focus).<button>s in the tab order — the whole chip is its remove button, with an accessible name from chip_remove_label that contains the visible label (WCAG 2.5.3 Label in Name); the × glyph is aria-hidden. role="combobox" stays on the always-focusable input. Add/remove is announced through the component's polite live region (chip_announcement). Keyboard: Backspace in the empty input removes the last chip; ← at caret 0 enters the chip row; ←/→ traverse chips; Delete/Backspace on a focused chip removes it (focus moves to the next chip, else previous, else the input); Esc clears the query text but never chips.order places chips visually before it. This keeps a wrapping <label>, as used by .input-group, associated with the input: a label targets its first labelable descendant. If a chip came first, clicking the field or selecting an option could activate that chip's remove button. Tab reaches the input first; chips follow in tab order and can also be reached with ←.
Escape can restore the typed query after arrow navigation, close the list or clear the input. In chips mode it clears only the query. When it handles any of these, the component cancels the event without stopping propagation. Ancestor handlers must check e.defaultPrevented. Checking whether a list is open is insufficient: restoring a query leaves it open, and clearing an input does not require an open list. The combobox's own keydown handler likewise ignores events that another handler has already cancelled.
When the list is closed and there is no tentative query or input to clear, the combobox neither cancels nor stops Escape. The enclosing dialog or form can then handle it. The list uses popover="manual" so a browser close watcher cannot act independently of this handler, as it would with auto.
Check e.defaultPrevented to distinguish an Escape event the combobox handled from one it left alone. Menus and notification panels also cancel without stopping propagation. A popover additionally stops propagation, so its handled Escape events do not reach bubbling ancestor listeners.
A select also handles Escape only when it has something to close. Its picker uses popover="auto", however, so cancellation must also prevent the browser's close watcher from closing an enclosing overlay after the picker has closed. The combobox uses popover="manual" and has no browser close watcher.
The inner <input> carries role="combobox", so its accessible name
is what screen readers announce. Three patterns, in preference order:
<label for> — preferred. Give the author-supplied
input an explicit id and point a <label> at it. The label stays
visible when the user types, which helps everyone.
<label for> — when a visible label would be
redundant (e.g. a search field whose purpose is obvious from surrounding context). The label is
still in the DOM and announced by screen readers.
aria-label on the input — last resort, for cases like a decorated
.input-group where adding a separate label element would break the layout. Put
aria-label directly on the author-supplied <input>.
Do not rely on placeholder as the accessible name. Placeholders vanish once the
user types, and they cause the listbox's own accessible name (which mirrors the input) to become
the typed query rather than the field purpose.
The inline completion is rendered by a sibling DOM mirror (marked aria-hidden), not
by mutating input.value. This avoids async-input-event races on mobile virtual
keyboards and during IME composition, at the cost of losing the traditional screen-reader channel
for inline autocomplete. Arrow-nav, however, DOES update input.value programmatically
(Google-style preview); this surfaces the canonical option to assistive tech the moment the user
arrows to it.
<x-select> — when you need a closed select with custom styling but no free-text or async options..input-group) — how [raw] mode pairs with prefix/suffix decoration and a wrapper-owned border.ui-combobox.mjs