Component contract
Every autocomplete in the admin is <UiAutocomplete>. The linter scans the React source for any input whose role is combobox and asserts it's rendered through <UiAutocomplete>. No hand-rolled comboboxes.
fetchOptions: (query) => Promise<Option[]>— async; debounced by the component (250ms default).onSelect: (option) => void— fires once when the user picks a result.placeholder?: string— input placeholder.label: string— visible label, always rendered above the input.renderOption?: (option) => ReactNode— defaults to a one-line text render.noResultsText?: string— defaults to "No results found." Shown after a successful fetch returns an empty list.minQueryLength?: number— defaults 0 (search starts on first keystroke). Set to 2-3 for endpoints with broad result sets.debounceMs?: number— defaults 250. Below 100 is a usability regression; the component clamps to a minimum.allowFreeText?: boolean— defaults false. When true, Enter accepts the typed query as a custom value.
Interaction surface
-
Mount: input + dropdown root.
The input has
role="combobox",aria-expanded="false"initially, andaria-controlspointing at the dropdown id. The dropdown is in the DOM but hidden until the user types or focuses with a non-empty value. -
User types.
Each keystroke updates the input value AND restarts the debounce timer. Only when the timer expires (default 250ms) does
fetchOptionsfire. The dropdown shows a loading skeleton while the request is in flight;aria-busy="true"on the dropdown for screen readers. -
Results render.
Dropdown opens (
aria-expanded="true"). Each result is arole="option"element. The first result is NOT auto-highlighted by default — user has to ArrowDown to begin keyboard navigation. (Auto-highlighting causes accidental selections when the user types fast and the API returns slowly.)aria-activedescendantupdates as the user arrows through options. -
Cursor stability across re-renders.
If the user is on result 5 (highlighted) and the API returns updated results that include result 5 at a new position (say, position 3), the highlight follows the option's identity (by
option.id), not its index. If the highlighted option is no longer in the result set, highlight clears (does NOT auto-jump to the new first result, which would be a foot-gun for fast typers). -
Keyboard navigation.
ArrowDown / ArrowUp move highlight (with wrap-around). PageDown / PageUp jump 5. Home / End jump to first/last. Enter selects the highlighted option AND fires
onSelectAND closes the dropdown AND moves focus back to the input. Tab moves focus out without selecting (preserves the typed text in the input). -
Escape behavior — context-sensitive.
If the dropdown is open: Esc closes the dropdown without clearing the input. If the dropdown is closed AND the input has text: Esc clears the input. If the dropdown is closed AND the input is empty: Esc bubbles up (so a parent modal can close). This three-state behavior is the standard combobox pattern.
-
No results.
When fetch returns empty array, the dropdown shows
noResultsTextin a single non-clickable row (witharia-live="polite"so screen readers announce). The dropdown stays open so the user can refine the query without the disorienting close-then-open transition. This is not an error state — visually muted, not red. -
Click outside closes.
Click anywhere outside the input or dropdown closes the dropdown. The input value is preserved. If
allowFreeText=trueand the input has unsubmitted text, the value is committed viaonSelect({ freeText: true, value: input.value }).
Failure modes
Race: fast typing, slow API
Trigger: user types "smith" then "smithson" in quick succession. The first request takes 800ms; the second takes 200ms. The second response arrives FIRST, then the first arrives and overwrites it.
The component must track request generation: each fetch increments a counter; only the latest fetch's response is rendered. Stale responses are discarded silently. The harness simulates this by stubbing two requests with controlled timing and asserting the rendered options match the LATEST query.
Recovery: Component fix — generation counter / abort prior fetch.
Cursor drift on re-render
Trigger: user highlights option at position 5 with arrow keys. The API returns a refined list that has option-5 now at position 3.
The highlight must follow the option's identity, not its position. If position-5 in the new list is a different option, highlight clears (does NOT auto-jump). The harness simulates by changing the result list while keeping the previously-highlighted option's id present at a new index, asserts highlight stays on that id.
Recovery: Component fix — track highlight by id, not index.
Auto-highlight first result
Trigger: a designer requests "highlight first option by default." User types "smith", API returns 3 results, first is auto-highlighted, user hits Enter to submit the form (intending to send the typed query) and instead selects the highlighted option.
Auto-highlight is rejected — the contract is "user must explicitly arrow to highlight before Enter selects." The harness asserts: type a query, wait for results, observe no aria-activedescendant until the user presses ArrowDown.
Recovery: Don't add auto-highlight. Push back on the request.
Escape closes the wrong thing
Trigger: autocomplete is inside a modal. Dropdown is open. User hits Esc expecting to close the dropdown only; instead the modal closes too because the keydown bubbled up.
Escape must stopPropagation() when it has work to do (close dropdown, clear input). Only when neither applies does it bubble. The harness simulates an autocomplete inside a modal and asserts: open dropdown → Esc → dropdown closed AND modal still open.
Recovery: Component fix — stopPropagation when consuming Escape.
No-results state styled like an error
Trigger: user types a query that legitimately has no results (e.g., a name they're about to add for the first time). The dropdown shows "No results found" in red, with an alert icon, and a subtle shake animation.
No results is not an error. Style is muted (--ink-faint), no icon, no animation. The user is mid-task; this is informational. Red + alert + shake reads "you did something wrong," which is actively misleading. The harness asserts: empty result, the no-results row's color is the muted token, no role="alert", no animated transform.
Recovery: CSS fix.
Debounce too aggressive: typing feels broken
Trigger: a heuristic-tuner sets debounceMs=600 to "save API calls." User types "smith" and waits 600ms before any feedback.
Debounce above 350ms feels broken to most users. Component clamps debounceMs to the range [100, 350]. Below 100 wastes API calls without UX benefit; above 350 the user starts to wonder if the input is hung. The harness asserts: pass debounceMs=600, observe actual debounce is 350.
Recovery: Component fix — clamp the prop.
Loading skeleton flickers on fast responses
Trigger: API responds in 80ms. Skeleton renders for 80ms then disappears. Visible flicker.
Skeleton is suppressed if the response arrives within 200ms of the fetch starting. Either the user sees nothing-then-results (fast feel) or skeleton-then-results (clear loading feel) — never the in-between flicker. The harness simulates a fast response, asserts no skeleton ever rendered.
Recovery: Component fix — suppression threshold.
Tab inside dropdown selects the highlighted option
Trigger: user has dropdown open with option 3 highlighted. Hits Tab expecting to move focus to the next form field.
Tab moves focus out WITHOUT selecting. The typed query stays in the input. (Tabbing into the autocomplete from the previous field is symmetric — focus lands in the input, dropdown does not auto-open.) The harness asserts Tab from a highlighted state does not fire onSelect.
Recovery: Component fix — Tab semantics.
Accessibility
- Input has
role="combobox",aria-haspopup="listbox",aria-controlspointing at the dropdown id,aria-expandedreflecting open/closed. - Dropdown has
role="listbox"; each option hasrole="option"with a unique id. aria-activedescendanton the input updates as the user arrows through options.aria-busy="true"on the dropdown while a fetch is pending.- Result-count announcement: a live region announces "5 results" or "No results" when the result set updates. Live region is
aria-live="polite"+aria-atomic="true"so it doesn't spam the user as they type — it only re-announces when the count actually changes. - No-results row has
aria-live="polite"but is NOTrole="alert"(because it's not an error). - Color contrast: input text ≥ 4.5:1, options ≥ 4.5:1, highlighted option's background contrast ≥ 3:1 against unhighlighted, focus ring ≥ 3:1.
- Pass axe-core with no
seriousorcriticalviolations.
Stable test attributes
Component-level contract. Every <UiAutocomplete> instance MUST expose these.
Visibility teeth. Each attribute must be present AND effectively visible when the relevant state is active. The dropdown attributes are absent (or fail visibility) when the dropdown is closed — that's correct. The teeth catch hiding the dropdown via opacity:0 while keeping it "open" to dodge a probe.
| data-test | Where | Purpose |
|---|---|---|
ui-autocomplete | Wrapping <div> | Component identity marker |
ui-autocomplete-input | The combobox input | Carries role="combobox", aria-expanded |
ui-autocomplete-dropdown | The listbox | Visible only when expanded; role="listbox" |
ui-autocomplete-option | Inside dropdown | Each option row; role="option" with unique id |
ui-autocomplete-option-highlighted | Inside dropdown | The currently keyboard-highlighted option; matched by aria-activedescendant |
ui-autocomplete-skeleton | Inside dropdown | Loading skeleton; only visible during fetch > 200ms |
ui-autocomplete-no-results | Inside dropdown | No-results row; never role=alert; muted styling |
ui-autocomplete-count-live | Outside dropdown | aria-live region announcing result counts |
Agent test plan
Standalone probes run against /admin-test/ui-autocomplete-fixture with controlled timing stubs for the fetchOptions function so race conditions can be tested deterministically.
Probe list
- debounce-default-250ms: type 5 chars in 100ms total, assert exactly 1 fetchOptions call after 250ms idle
- debounce-clamps-low: pass debounceMs=20, assert effective debounce is 100ms minimum
- debounce-clamps-high: pass debounceMs=600, assert effective debounce is 350ms maximum
- race-stale-response-discarded: stub two fetches, response 2 returns first then response 1 returns; assert dropdown shows results from query 2
- aborted-stale-fetch: stub a fetch with controlled abort; type new query mid-flight; assert old fetch's request was aborted (AbortController)
- skeleton-suppressed-fast: stub fetch with 80ms latency; assert no ui-autocomplete-skeleton ever rendered
- skeleton-shown-slow: stub fetch with 500ms latency; assert ui-autocomplete-skeleton visible during request
- highlight-by-id-not-index: highlight option id="x" at position 5; refresh results so id="x" is at position 2; assert ui-autocomplete-option-highlighted has data-option-id="x"
- highlight-cleared-when-id-gone: highlight option id="x"; refresh results without id="x"; assert no option highlighted
- no-auto-highlight: type query, wait for results, assert aria-activedescendant on input is empty until ArrowDown
- arrow-down-highlights: ArrowDown 3 times, assert option at position 3 highlighted
- arrow-up-wraps: at position 0, ArrowUp, assert option at last position highlighted
- enter-selects-and-fires: highlight option, Enter, assert onSelect called once with that option AND dropdown closed AND focus on input
- tab-no-selection: highlight option, Tab, assert onSelect NOT called AND focus moved out
- esc-open-closes-dropdown: dropdown open, Esc, assert dropdown closed AND input value preserved
- esc-closed-with-text-clears: dropdown closed, input has "smith", Esc, assert input value === ""
- esc-closed-empty-bubbles: dropdown closed, input empty, Esc, assert event NOT stopPropagated (parent modal would receive)
- click-outside-closes: open dropdown, click outside component, assert dropdown closed AND input value preserved
- no-results-styling: stub fetch returns [], assert ui-autocomplete-no-results visible AND not role=alert AND color is muted
- live-region-announces-on-change: stub fetch returns 3 results, assert ui-autocomplete-count-live text matches "3 results"
- live-region-suppresses-keystroke-spam: type 5 chars, assert live region announces only when results change (not on every keystroke)