Components / selection
Combobox
Menu, Select and Combobox are three components and one temptation. A menu fires an action; a select holds a value from a closed set; a combobox holds a value and lets you type toward it.
Summary
A text input that names a list — filter a long set, or offer a styleable alternative to <select>.
| APG pattern | Combobox |
| Built on | native elements |
| Status | stable |
| Since | 0.1.0 |
| Also under | forms |
<Combobox id="country" label="Country" placeholder="Start typing…" options={countries} onSelect={setCountry} />
// The closest thing to a plain dropdown: every option visible, no filtering.
<Combobox id="visibility" label="Visibility" behavior="list" options={levels} onSelect={setLevel} />
This is the dropdown. Select has no component and will not get one — ARCHITECTURE.md 2.17 records why: a native <select> cannot style or enrich its options, so a styled one is a lie about what the element can do. Everything a select is reached for lives here, which is why this is cross-listed under forms as well as selection. A reader building a form should not have to know the word combobox to find it.
behavior="list" is the select-shaped case. Every option stays visible and typing moves the highlight rather than narrowing the set. Use filter, the default, once the list is long enough that reading it is the slow part.
Anatomy
A text input with a popup listbox. The popup reuses Listbox rather than restating it.
| Part | Element | Required |
|---|---|---|
control | <input> | yes |
control-wrap | <div> | yes |
description | <p> | no |
error | <p> | no |
label | <label> | no |
option | the caller's | yes |
popup | <div> | yes |
trigger | <button> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
options | (ListboxOption | ListboxGroup)[] | required | The full set, flat or grouped. Filtering is the combobox's, so pass everything. |
value | string | none | The text in the input. |
defaultValue | string | `` | The value selected on first render. |
onChange | (text: string) => void | none | Fires on every keystroke. |
onSelect | (value: string) => void | none | Fires when an option is committed, with that option's value. |
label | React.ReactNode | none | The visible label and the accessible name of the input. |
description | React.ReactNode | none | A line under the control. Replaced by error when there is one. |
error | React.ReactNode | none | Marks the input invalid and replaces the description, so there is never a second message competing to be the current one. |
placeholder | string | none | Earns its place here, because typing is the interaction and nothing else says so. It is not a label. |
disabled | boolean | none | Removes the input from the tab order and closes the popup. |
behavior | filter · list | filter | filter narrows the list as the reader types. list keeps every option visible and opens on focus — the closest thing to a styleable <select>. |
match | start · contains | start | How typing narrows the list. start (default) matches Listbox's typeahead. contains finds more but surprises — typing "ne" also offers Cha-nne-l Islands. Worth it only when the useful word is rarely first in the label. |
defaultOpen | boolean | false | Renders with the popup already open — for a specimen or a test. |
id | string | required | Required — used to build the input, list and option ids. |
States
| State | Expressed as | |
|---|---|---|
| Disabled | :disabled | |
| Focused | :focus-visible | |
| Hover | :hover | |
| Invalid | data-invalid | |
| Open or closed | data-state |
Behavior
Focus never leaves the input. The active option is named by aria-activedescendant, so typing and arrowing are one gesture. Moving DOM focus into the list is the classic mistake, and it breaks both typing and the screen-reader announcement of what was typed.
Alt+ArrowDown opens the popup without moving into the list, so a reader can open it and keep reading their own text. Getting that backwards was a defect the upstream port found.
Accessibility
aria-controls is written only while the popup is open. It dangled while closed — which is most of a combobox's life — and a dangling reference is a promise to a screen reader that is not kept.
aria-expanded on the input says whether the list is showing.
Fifty-eight upstream keyboard requirements across five popup types make this the heaviest pattern in the suite. What is ported and what is not applicable is recorded in test/upstream/PORT.md.
Keyboard
| Key | Result |
|---|---|
Arrow Down / Arrow Up | Move the active option, opening the popup if it is closed |
Alt + Arrow Down | Opens the popup without moving into the list |
Home / End | Jump within the list |
Enter | Selects the active option |
Escape | Closes the popup, keeping what was typed |
Tab | Closes and moves on |
| typing | Filters |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-activedescendant | set at render |
aria-autocomplete | set at render |
aria-controls | set at render |
aria-describedby | set at render |
aria-expanded | set at render |
aria-hidden | always true |
aria-invalid | set at render |
aria-selected | always false |
role | always combobox |
Focus
Focus never leaves the input. Moving DOM focus into the list is the classic mistake — it breaks typing and it breaks the announcement of what was typed. Alt + Arrow Down opening without moving in was backwards until the upstream port found it.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.
Tested
test/apg/batch-d.test.jsxtest/apg/partials-sweep.test.jsxtest/browser/site.spec.mjstest/browser/upstream-combobox.spec.mjs
Known gaps
- 37 upstream refs — no datepicker component
- 12 upstream refs — their popup is a grid; ours is a listbox
- 14 upstream refs — ours is always editable; blocked on the same thing as Select
- 1 upstream ref — no inline completion — aria-autocomplete="both" is a capability gap
- 1 upstream ref — APG's own page or markup, not the pattern
Appearance
| Token | Used for |
|---|---|
--se-accent | border-color |
--se-border-hover | border-color |
--se-border-strong | border |
--se-border-width | border |
--se-control-h | block-size |
--se-duration-fast | animation, transition |
--se-duration-instant | transition |
--se-ease-decelerate | animation |
--se-ease-standard | transition |
--se-focus-color | outline |
--se-focus-width | outline, outline-offset |
--se-icon-sm | block-size, inline-size, padding-inline |
--se-layer-floating | z-index |
--se-leading-body | line-height |
--se-leading-ui | line-height |
--se-negative | border-color, color |
--se-radius-md | border-radius |
--se-shadow-floating | box-shadow |
--se-space-1 | gap, margin-block-start, padding |
--se-space-2 | padding-inline |
--se-space-3 | inset-inline-end, padding-inline |
--se-surface | background |
--se-surface-sunk | background |
--se-target-min | min-block-size |
--se-text | color |
--se-text-disabled | color |
--se-text-muted | color |
--se-text-placeholder | color |
--se-text-sm | font-size |
--se-weight-medium | font-weight |
Motion
The popup rises over --se-duration-fast.
Content
The label names the value. A placeholder that says Start typing… earns its place here, because typing is the interaction and the control does not otherwise say so.
Usage
This is the answer to the Select gap. A native <select> cannot style its options or render anything but text in them; this can, and it keeps the keyboard model the platform would have given you.
When not to use it. For a short list with no filtering — that is a Listbox, and a text input in front of five options is a step the reader did not need.
Notes
Menu, Select and Combobox are three components and one temptation. A menu fires an action; a select holds a value from a closed set; a combobox holds a value and lets you type toward it.