← All stories

COMPONENT · ui-autocomplete

ui-autocomplete

Component Tier 1 (primitive) The most failure-prone primitive

Autocomplete looks simple — type, see suggestions, pick one. The reality is a tangle: debouncing keystrokes, racing async fetches, keeping the cursor stable as options rerender, escape clearing the right thing, screen readers announcing "27 results" without spamming, no-results states that don't feel like an error. Doing it once means every source-picker, guest-search, address-book-lookup, and tag-input in the admin inherits the answers.

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

  1. Mount: input + dropdown root.

    The input has role="combobox", aria-expanded="false" initially, and aria-controls pointing at the dropdown id. The dropdown is in the DOM but hidden until the user types or focuses with a non-empty value.

  2. User types.

    Each keystroke updates the input value AND restarts the debounce timer. Only when the timer expires (default 250ms) does fetchOptions fire. The dropdown shows a loading skeleton while the request is in flight; aria-busy="true" on the dropdown for screen readers.

  3. Results render.

    Dropdown opens (aria-expanded="true"). Each result is a role="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-activedescendant updates as the user arrows through options.

  4. 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).

  5. 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 onSelect AND closes the dropdown AND moves focus back to the input. Tab moves focus out without selecting (preserves the typed text in the input).

  6. 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.

  7. No results.

    When fetch returns empty array, the dropdown shows noResultsText in a single non-clickable row (with aria-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.

  8. Click outside closes.

    Click anywhere outside the input or dropdown closes the dropdown. The input value is preserved. If allowFreeText=true and the input has unsubmitted text, the value is committed via onSelect({ 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-controls pointing at the dropdown id, aria-expanded reflecting open/closed.
  • Dropdown has role="listbox"; each option has role="option" with a unique id.
  • aria-activedescendant on 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 NOT role="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 serious or critical violations.

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-testWherePurpose
ui-autocompleteWrapping <div>Component identity marker
ui-autocomplete-inputThe combobox inputCarries role="combobox", aria-expanded
ui-autocomplete-dropdownThe listboxVisible only when expanded; role="listbox"
ui-autocomplete-optionInside dropdownEach option row; role="option" with unique id
ui-autocomplete-option-highlightedInside dropdownThe currently keyboard-highlighted option; matched by aria-activedescendant
ui-autocomplete-skeletonInside dropdownLoading skeleton; only visible during fetch > 200ms
ui-autocomplete-no-resultsInside dropdownNo-results row; never role=alert; muted styling
ui-autocomplete-count-liveOutside dropdownaria-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)