Skip to content

Combobox (typeahead select)

components specs/components/combobox.kmd

Typeahead input + dropdown of filtered options with optional async loading, custom-value support, and multi-select variant. Replaces ad-hoc native `<select>` + JS combinations that vary in a11y across Koder products. Modeled after Duet, Polaris, Spectrum.

When this spec applies

Primary triggers

All triggers

Specification body

Component — Combobox

Status: v0.1.0 — Draft.

R1 — Anatomy

  • Input cell: text input + trailing caret icon + optional leading icon.
  • Dropdown popover: option list anchored to input; opens below by default, flips above if no space.
  • Each option: leading icon (optional) + label + secondary text (optional) + trailing checkmark (selected state in multi-mode).

R2 — Behaviors

BehaviorDefaultConfigurable
Typeahead filtersubstring match on labelfull-text matcher overridable
Async option loadoffcallback returning Promise<Option[]>
Free-text custom valueofftoggle to accept user-typed values not in list
Multi-selectofftoggle; selected values render as chips in input cell
Group separatorsnoneoptions may carry group field, renders sticky group heading

R3 — ARIA semantics

  • Container: role="combobox" with aria-expanded, aria-controls (pointing at listbox), aria-autocomplete="list".
  • Listbox: role="listbox" with each option role="option"; aria-selected reflects selection.
  • Multi mode: listbox aria-multiselectable="true".
  • Active descendant: aria-activedescendant points at the visually-highlighted option during keyboard nav.

R4 — Keyboard

KeyAction
Open dropdown (if closed); highlight next option
Highlight previous option
EnterSelect highlighted option; in custom-value mode and no match, commit input value as value
TabCommit selection + move focus out
EscClose dropdown without changing selection
Backspace (in multi, empty input)Remove the last chip
Home / EndHighlight first / last option
TypeFilters list; highlights first match

R5 — States

  • Loading (async): spinner inside dropdown; live-region announces "Loading options".
  • No results: empty-state-style cell inside dropdown ("No matches"); if custom value allowed, hint to "Press Enter to add".
  • Error: dropdown shows red message with retry button.
  • Disabled: input + caret muted, typing blocked, keyboard ignored.

R6 — i18n

  • All UI strings (placeholder, "Loading", "No matches", "Add custom") translatable per specs/i18n/contract.kmd.
  • Option labels rendered as-is from data; locale formatting is the consumer's responsibility.

R7 — OUIA

Per specs/testing/ouia-test-hooks.kmd:

  • data-ouia-component-type="Combobox"
  • data-ouia-safe="true" only when no pending async load and no open popover.

Não-escopo

  • Virtualized option list for huge option counts (separate ticket if pursued).
  • Tag-input mode (free-form chips without dropdown) — that's a different primitive.

Requirements (testable)

Requirement: Typeahead filtering and custom values {#req-combobox-typeahead}

The combobox SHALL filter the option list by substring match on option label as the user types, highlighting the first match; when free-text custom value mode is enabled and the typed value matches no option, pressing Enter SHALL commit the typed input as the value.

Scenario: Typing filters the list to substring matches

  • GIVEN a combobox with options ["Apple", "Banana", "Grape"] and default substring matcher
  • WHEN the user types "ap"
  • THEN the dropdown shows only "Apple" and "Grape"
  • AND the first match "Apple" is highlighted

Scenario: Enter commits a typed custom value with no match

  • GIVEN a combobox with custom-value mode enabled and options that do not include "Mango"
  • WHEN the user types "Mango" and presses Enter
  • THEN the committed value equals "Mango"
  • AND the dropdown closes

Requirement: ARIA combobox semantics {#req-combobox-aria}

The container SHALL expose role="combobox" with aria-autocomplete="list" and an aria-expanded state that reflects whether the popover is open, aria-controls SHALL reference the listbox element, the option list SHALL use role="listbox" with each option role="option", and in multi-select mode the listbox SHALL carry aria-multiselectable="true".

Scenario: aria-expanded tracks popover open state

  • GIVEN a closed combobox whose container has aria-expanded="false"
  • WHEN the user opens the dropdown
  • THEN the container has aria-expanded="true"
  • AND aria-controls references the element with role="listbox"

Scenario: Multi-select listbox is marked multiselectable

  • GIVEN a combobox configured in multi-select mode
  • WHEN the dropdown is open
  • THEN the listbox element has aria-multiselectable="true"
  • AND each option element has role="option" with an aria-selected value

Requirement: Keyboard navigation and active descendant {#req-combobox-keyboard}

The combobox SHALL support keyboard operation: ArrowDown opens a closed dropdown and highlights the next option, ArrowUp highlights the previous option, Home/End highlight the first/last option, Escape closes the dropdown without changing selection, and aria-activedescendant SHALL point at the currently highlighted option during keyboard navigation.

Scenario: ArrowDown opens the dropdown and highlights first option

  • GIVEN a closed combobox with focus on the input
  • WHEN the user presses ArrowDown
  • THEN the dropdown opens
  • AND the first option is highlighted
  • AND aria-activedescendant references that highlighted option's id

Scenario: Escape closes without changing selection

  • GIVEN an open combobox with a previously committed selection "Banana" and "Grape" currently highlighted
  • WHEN the user presses Escape
  • THEN the dropdown closes
  • AND the committed selection remains "Banana"

Requirement: Loading and no-results states {#req-combobox-states}

While an async option load is pending the dropdown SHALL show a spinner and a live region SHALL announce "Loading options"; when filtering yields no options the dropdown SHALL render a "No matches" empty state, and if custom-value mode is enabled that state SHALL include the hint to press Enter to add.

Scenario: Async load shows spinner and announces loading

  • GIVEN a combobox whose async loader returns a Promise that has not yet resolved
  • WHEN the user opens the dropdown to trigger the load
  • THEN a spinner is shown inside the dropdown
  • AND the live region text is "Loading options"

Scenario: No matches shows empty state with add hint

  • GIVEN a combobox with custom-value mode enabled and options ["Apple", "Grape"]
  • WHEN the user types "zzz" which matches no option
  • THEN the dropdown shows the "No matches" empty state
  • AND the state includes a hint to press Enter to add the custom value

Requirement: OUIA test hooks {#req-combobox-ouia}

The combobox SHALL emit data-ouia-component-type="Combobox" and SHALL set data-ouia-safe="true" only when there is no pending async load and no open popover; otherwise data-ouia-safe SHALL be "false".

Scenario: Not safe while popover is open

  • GIVEN a combobox with no pending async load
  • WHEN the dropdown popover is open
  • THEN data-ouia-component-type equals "Combobox"
  • AND data-ouia-safe equals "false"

Scenario: Safe when idle and closed

  • GIVEN a combobox with no pending async load
  • WHEN the dropdown is closed
  • THEN data-ouia-safe equals "true"

References