cswork / libraryOpen practice workspace

Articles

Accessible Combobox Coding Interview: Keyboard Navigation, Focus, and Async Results

Prepare for an accessible combobox coding interview with keyboard rules, focus management, async race handling, status messages, and concrete test cases.

An accessible combobox coding interview is a test of consistent behavior: typing, arrow keys, pointer selection, and network responses must agree about what the user is choosing. Keep DOM focus in the editable input, track the active suggestion separately from the committed value, and prevent obsolete requests from changing the current interface.

For broader foundations, start with the frontend accessibility interview guide.

Evidence boundary: W3C specifications define ARIA semantics; its Authoring Practices Guide (APG) offers implementation guidance. The interaction contract, request timeline, and test cases below are original practice. Candidate report: a 2021 first-person account describes an autocomplete system-design interview, but does not establish current interview frequency or these accessibility requirements. Sources were checked September 21, 2026.

Define the interaction contract before coding

Ask whether the field accepts arbitrary text or requires a listed value, whether suggestions appear automatically, and what should happen when the user leaves without choosing. Those answers determine validation and selection behavior. For a short fixed choice list, first consider a native select; use a custom editable widget when the search interaction requires it.

Our exercise contract: the City field accepts free text and fetches suggestions after a 200 ms pause. Results appear without automatically highlighting a city. Arrow keys navigate; Enter or a pointer click accepts a suggestion. Tab preserves the typed string and leaves. Escape closes suggestions without clearing the text. These are explicit product choices, not universal requirements.

Keep the first version bounded: every result is enabled, there is one selected city at most, and the list is small enough to render completely. Multi-select tokens, disabled options, and virtualization introduce additional contracts. Explain those extensions after the basic flow works.

A useful opening explanation is: “I’ll make text editing work first, then add a listbox with virtual focus, then protect every asynchronous state update.” That sequence exposes the risks before styling consumes the available time.

Separate input focus, active option, and accepted value

Suppose the field contains San, the results are San Diego and San Jose, and the first option is highlighted. Three facts coexist: the input owns DOM focus; San Diego is the active option; no city ID has been accepted. Treating them as one selectedIndex makes typing, closing, and submitting difficult to reason about.

Track query, items, activeId, selectedCity, request status, and whether suggestions are wanted. Editing the text clears selectedCity in this exercise, so the form cannot submit an old city ID alongside a new label. Use stable option IDs, scoped to the component instance; duplicate display labels are not reliable identities.

Official semantics: WAI-ARIA 1.2 defines the combobox role and its popup relationship. Here is one rendered snapshot while San Diego is active:

<label for="city-input">City</label>
<input id="city-input" type="text" value="San"
  role="combobox" aria-autocomplete="list"
  aria-expanded="true" aria-controls="city-options"
  aria-activedescendant="city-option-sd">
<ul id="city-options" role="listbox" aria-label="City suggestions">
  <li id="city-option-sd" role="option" aria-selected="true">
    San Diego
  </li>
  <li id="city-option-sj" role="option" aria-selected="false">
    San Jose
  </li>
</ul>
<p role="status">2 suggestions available.</p>

The popup’s selected option is the current navigation candidate; it does not mean our form has committed a city. On acceptance, copy the option’s ID and label into application state, close the popup, and remove aria-activedescendant. When no option is active, omit that attribute rather than pointing at a missing element.

The input’s accessible name remains “City”; its value changes independently. A placeholder is not a substitute for that label. Set aria-expanded from actual popup visibility. Ensure the controlled list and referenced option exist when exposed. ARIA describes the interaction; event handlers still have to implement it.

Make keyboard behavior predictable

APG guidance: the combobox pattern describes arrow navigation, acceptance, dismissal, and preserving normal text editing. Popup options are outside the page’s Tab sequence. Our editable listbox implementation keeps DOM focus on the input and updates the active descendant.

InputBehavior in this exercise
ArrowDownOpen available results; activate the first option, then advance, stopping at the last.
ArrowUpActivate the last option when none is active; otherwise move back, stopping at the first.
EnterAccept the active option only when the popup is open and the option still exists.
EscapeClose, clear active state, and invalidate pending suggestion work; preserve text.
Tab / Shift+TabLeave normally, dismiss suggestions, and preserve typed text without accepting the highlight.
Left/Right, Home/EndReturn to text editing, clear the active highlight, and preserve the browser’s caret behavior.

The boundary rules and Tab acceptance policy belong to this exercise. State them rather than silently borrowing behavior from a different autocomplete variant. Call preventDefault() for a handled navigation or acceptance action, not for every keydown. Enter without an active option should retain the enclosing form’s agreed behavior.

Scroll the active option into view after its element exists. The APG example explicitly handles this because changing aria-activedescendant does not perform browser focus scrolling. Preserve a visible input focus indicator and a distinguishable active-option style, including under zoom and forced colors.

Reject stale results before the debounce timer fires

Debounce reduces request volume. It does not determine whether a completed response belongs to the current interaction.

Original failure trace: a request for San is already running at 0 ms. At 50 ms, the user types Santa; its debounced request will start at 250 ms. The old San response arrives at 80 ms. If you increment the request generation only when a request starts, that old response still appears current during the waiting interval.

Advance the generation immediately on raw input. Cancel the old timer, abort supported work, clear old options and active state, and then schedule the new lookup. Keep success, error, and loading updates behind the same generation check.

Browser API fact: AbortController.abort() can abort fetch and response-body consumption. Engineering design: also check generation identity, so correctness does not depend on every asynchronous dependency honoring cancellation.

This coordinator demonstrates that boundary. search returns a promise of options; publish replaces the request status and result list. The UI handles focus, acceptance, and rendering separately.

function createLookup(search, publish, delay = 200) {
  let generation = 0, timer, controller;

  function invalidate() {
    generation += 1;
    clearTimeout(timer);
    controller?.abort();
    controller = undefined;
  }

  function dismiss() {
    invalidate();
    publish({ status: "idle", items: [] });
  }

  function input(query) {
    invalidate(); // Before waiting, not when fetch starts.
    const mine = generation;
    publish({ status: "idle", items: [] });
    if (!query.trim()) return;

    timer = setTimeout(async () => {
      if (mine !== generation) return;
      const current = new AbortController();
      controller = current;
      publish({ status: "loading", items: [] });
      try {
        const items = await search(query, current.signal);
        if (mine !== generation) return;
        publish({ status: "ready", items });
      } catch (error) {
        if (mine !== generation) return;
        publish({ status: "error", items: [] });
      }
    }, delay);
  }

  return { input, dismiss, dispose: invalidate };
}

Route every user edit through input. Route Escape, leaving the component, and accepting an option through dismiss; call dispose during teardown. A new edit can start a fresh lookup. Do not call input merely because accepting a result changed the displayed label, or it will launch another search.

The renderer clears activeId whenever options are replaced. It opens only for current, nonempty results while the input is focused and suggestions are wanted. A response cannot reopen a popup after Escape because dismissal invalidates its generation. If a newer response reorders options, retaining an array index could silently move the highlight to a different city.

Announce request state without stealing focus

Official guidance: W3C’s status-message explanation distinguishes result lists from short messages about waiting, completion, or failure. Expose those messages without moving focus away from the field.

Keep a status element mounted outside the popup so closing suggestions does not hide request feedback. Update it with concise text such as “Searching,” “2 suggestions available,” “No suggestions,” or “Suggestions unavailable; keep typing to retry.” A status role has polite live-region semantics. Do not make the entire list a live region and repeat every option on each update.

Treat an empty successful response differently from an error. Neither should become a selectable “No results” option. For our free-text contract, a failed suggestions request does not invalidate the typed city; a choose-from-list product would need a different validation rule.

Avoid announcing every keystroke or duplicating the active option in the status region. Generation checks must protect messages too: rejecting stale rows while announcing their old result count is still an inconsistent interface. If requests complete quickly, avoid a flurry of transient loading announcements.

Handle pointer selection and composition deliberately

Pointer selection exposes an ordering trap: pressing an option can blur the input before the option’s click handler runs. If blur immediately removes the list, the intended selection disappears.

One desktop strategy prevents the input from losing focus on an option’s primary mouse-down, then commits through the option’s click handler. Keep the commitment in the shared acceptance function used by Enter. Do not put a separate button inside every option or make each option a Tab stop.

That mouse strategy is not a universal touch solution. Preserve scrolling, support ordinary click activation, and verify pointer cancellation and touch behavior. A complete component needs a considered outside-interaction policy, especially if its popup is portaled outside the input’s DOM subtree. An arbitrary delay before closing can hide this bug without fixing the interaction contract.

Composition also changes what Enter means. Browser API fact: KeyboardEvent.isComposing identifies events during a composition session. Our policy ignores combobox navigation and acceptance while composing, so Enter can finish text composition instead of choosing a city.

On composition start, invalidate pending results and suspend new searches while allowing the input text to update. After composition ends, schedule the final text once. Account for the browser’s subsequent input event so the final value is not requested twice. Verify this with the actual input method; a synthetic keydown cannot establish IME compatibility.

Test transitions that can break the contract

We ran 12 local JavaScript checks with controlled timers and deferred promises against this coordinator. They verify stale success and failure handling, dismissal during loading, cancellation of a scheduled lookup, clearing, disposal, and resuming with a fresh edit. They do not test a complete DOM component or establish screen-reader compatibility.

For the rendered implementation, rehearse these observable failures:

  • Type San, move to San Jose, then type Santa. Old options and the active-descendant reference must disappear immediately.
  • Resolve Santa first and San last. Only the current results and their status may remain.
  • Press Escape while loading; resolve the promise afterward. The popup stays closed and the field keeps its text.
  • Highlight an option, then press Tab. Focus reaches the next control and no city ID is silently committed.
  • Click a suggestion, then edit its label. The stale committed ID must clear.
  • Navigate beyond the visible list area. The active option stays visible without moving DOM focus away from the input.

Assert accessible names, expanded state, active-option existence, focus location, and displayed values in browser tests. Add an automated accessibility scan, then test keyboard-only use and representative browser/screen-reader combinations. The APG example warns about support gaps; neither valid attributes nor a passing scan proves the experience works for every user.

Accessible combobox coding interview practice

QuestionWhat to rehearse
Implement an Accessible Autocomplete Search ComponentExplain input focus, active options, and acceptance separately.
Implement debounced async autocomplete inputReject obsolete responses during the debounce interval.
Implement robust debounce and throttle in JavaScriptDefine timer cleanup and cancellation boundaries.
Build a Debounced Autocomplete Search BarConnect loading, empty, error, and keyboard states.
Build a Reliable Streaming Chat UITransfer request-identity reasoning to another asynchronous interface.

For your next Frontend Engineer practice session, implement the city contract and deliberately return the requests in reverse order. Narrate which generation owns the results, which option is active, and what the form would submit. Those three answers make the design reviewable.

Sources and Further Reading