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.

Rendered from the package · open on its own

Summary

A text input that names a list — filter a long set, or offer a styleable alternative to <select>.

APG patternCombobox
Built onnative elements
Statusstable
Since0.1.0
Also underforms
<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.

PartElementRequired
control<input>yes
control-wrap<div>yes
description<p>no
error<p>no
label<label>no
optionthe caller'syes
popup<div>yes
trigger<button>yes

Properties

PropertyValuesDefaultWhat it does
options(ListboxOption | ListboxGroup)[]requiredThe full set, flat or grouped. Filtering is the combobox's, so pass everything.
valuestringnoneThe text in the input.
defaultValuestring``The value selected on first render.
onChange(text: string) => voidnoneFires on every keystroke.
onSelect(value: string) => voidnoneFires when an option is committed, with that option's value.
labelReact.ReactNodenoneThe visible label and the accessible name of the input.
descriptionReact.ReactNodenoneA line under the control. Replaced by error when there is one.
errorReact.ReactNodenoneMarks the input invalid and replaces the description, so there is never a second message competing to be the current one.
placeholderstringnoneEarns its place here, because typing is the interaction and nothing else says so. It is not a label.
disabledbooleannoneRemoves the input from the tab order and closes the popup.
behaviorfilter · listfilterfilter narrows the list as the reader types. list keeps every option visible and opens on focus — the closest thing to a styleable <select>.
matchstart · containsstartHow 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.
defaultOpenbooleanfalseRenders with the popup already open — for a specimen or a test.
idstringrequiredRequired — used to build the input, list and option ids.

States

StateExpressed as
Disabled:disabled
Focused:focus-visible
Hover:hover
Invaliddata-invalid
Open or closeddata-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

KeyResult
Arrow Down / Arrow UpMove the active option, opening the popup if it is closed
Alt + Arrow DownOpens the popup without moving into the list
Home / EndJump within the list
EnterSelects the active option
EscapeCloses the popup, keeping what was typed
TabCloses and moves on
typingFilters

Roles, states and properties

AttributeSet by
aria-activedescendantset at render
aria-autocompleteset at render
aria-controlsset at render
aria-describedbyset at render
aria-expandedset at render
aria-hiddenalways true
aria-invalidset at render
aria-selectedalways false
rolealways 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.jsx
  • test/apg/partials-sweep.test.jsx
  • test/browser/site.spec.mjs
  • test/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

TokenUsed for
--se-accentborder-color
--se-border-hoverborder-color
--se-border-strongborder
--se-border-widthborder
--se-control-hblock-size
--se-duration-fastanimation, transition
--se-duration-instanttransition
--se-ease-decelerateanimation
--se-ease-standardtransition
--se-focus-coloroutline
--se-focus-widthoutline, outline-offset
--se-icon-smblock-size, inline-size, padding-inline
--se-layer-floatingz-index
--se-leading-bodyline-height
--se-leading-uiline-height
--se-negativeborder-color, color
--se-radius-mdborder-radius
--se-shadow-floatingbox-shadow
--se-space-1gap, margin-block-start, padding
--se-space-2padding-inline
--se-space-3inset-inline-end, padding-inline
--se-surfacebackground
--se-surface-sunkbackground
--se-target-minmin-block-size
--se-textcolor
--se-text-disabledcolor
--se-text-mutedcolor
--se-text-placeholdercolor
--se-text-smfont-size
--se-weight-mediumfont-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.