AniUI Academy

Build a keyboard-first combobox

hardAccessibilityHigh freq+250 XP

Build a combobox: a text input with a dropdown list of options that filter as you type.

The input is the focus throughout — options are navigated via arrow keys without stealing focus. This is the correct ARIA pattern for combobox and is what every serious FE loop tests.

Examples

// Type "b"
// → listbox shows Bengaluru, Berlin, Boston, Mumbai
// → input has aria-expanded="true"

// Press ArrowDown
// → input.aria-activedescendant = "opt-0"

// Press Enter
// → input.value = "Bengaluru", list closes.

// Press Escape (while open)
// → list closes, input value unchanged.

Focus stays on the input the entire time; the listbox is addressed by id, not focused directly.

<input
  role="combobox"
  aria-expanded={showList ? "true" : "false"}
  aria-controls="combo-list"
  aria-activedescendant={
    highlighted !== null ? `opt-${highlighted}` : undefined
  }
  onKeyDown={onKeyDown}
/>

`aria-activedescendant` is how a screen reader knows which option is "focused" without the DOM focus moving.

What it has to do

  • The input is `<input role="combobox" data-testid="combo-input" />`.
  • The listbox is `<ul role="listbox" id="combo-list">` — only present in the DOM when there is at least one filtered option and the box is "open".
  • Each option is `<li role="option" id="opt-{index}">`.
  • The input carries `aria-expanded="true"` when the list is shown, `"false"` otherwise.
  • ArrowDown opens the list on first press from an empty highlight state, otherwise moves highlight down. ArrowUp moves highlight up. Both wrap at the ends.
  • The currently-highlighted option sets `aria-activedescendant` on the input to the option's id — focus never leaves the input.
  • Enter commits the highlighted option into the input value and closes the list. Escape closes without committing.

Notes

  • Arrow keys must `preventDefault()` or the browser will scroll the page / move the caret inside the input.
  • Wrap the highlight with `((i % n) + n) % n` so JavaScript's negative-modulo quirk doesn't send ArrowUp from 0 to -1.
  • Reset the highlight to `null` on each `onChange` — the previous highlight index may no longer map to the filtered list.
  • This is the ARIA 1.2 combobox pattern (input-with-listbox, `aria-activedescendant` variant). Moving DOM focus onto options is a different, less common pattern and will fail the test suite here.
Asked atMicrosoftGoogleAtlassianMetaStripe

Stuck? The react course covers the ideas this problem is built on.

import { useState } from "react";

const CITIES = ["Bengaluru", "Berlin", "Boston", "Mumbai", "Munich", "New York", "Paris", "Tokyo", "Toronto"];

export default function App() {
  const [query, setQuery] = useState("");
  const [highlighted, setHighlighted] = useState(null);
  const [open, setOpen] = useState(false);

  // TODO: derive filtered options; handle ArrowUp/ArrowDown/Enter/Escape;
  // keep aria-expanded + aria-activedescendant in sync; focus must stay
  // on the input throughout.
  const options = [];

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, maxWidth: 320 }}>
      <input
        role="combobox"
        data-testid="combo-input"
        aria-expanded="false"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        style={{ width: "100%", padding: 8, fontSize: 14 }}
      />
      {open && options.length > 0 ? (
        <ul role="listbox" id="combo-list" style={{ listStyle: "none", padding: 0, border: "1px solid #ddd", marginTop: 4 }}>
          {options.map((city, i) => (
            <li
              key={city}
              role="option"
              id={`opt-${i}`}
              style={{ padding: 8 }}
            >
              {city}
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  );
}

6 tests · +250 XP

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.