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
- Pick a combobox component for a Koder product form
All triggers
- Add a searchable select to a form
- Replace native select-with-search with the canonical primitive
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
| Behavior | Default | Configurable |
|---|---|---|
| Typeahead filter | substring match on label | full-text matcher overridable |
| Async option load | off | callback returning Promise<Option[]> |
| Free-text custom value | off | toggle to accept user-typed values not in list |
| Multi-select | off | toggle; selected values render as chips in input cell |
| Group separators | none | options may carry group field, renders sticky group heading |
R3 — ARIA semantics
- Container:
role="combobox"witharia-expanded,aria-controls(pointing at listbox),aria-autocomplete="list". - Listbox:
role="listbox"with each optionrole="option";aria-selectedreflects selection. - Multi mode: listbox
aria-multiselectable="true". - Active descendant:
aria-activedescendantpoints at the visually-highlighted option during keyboard nav.
R4 — Keyboard
| Key | Action |
|---|---|
| ↓ | Open dropdown (if closed); highlight next option |
| ↑ | Highlight previous option |
| Enter | Select highlighted option; in custom-value mode and no match, commit input value as value |
| Tab | Commit selection + move focus out |
| Esc | Close dropdown without changing selection |
| Backspace (in multi, empty input) | Remove the last chip |
| Home / End | Highlight first / last option |
| Type | Filters 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
specs/components/buttons.kmdspecs/components/index-filters.kmdspecs/i18n/contract.kmdspecs/testing/ouia-test-hooks.kmd