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.
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> ); }
- 1.The input is `<input role="combobox" data-testid="combo-input" />`.
- 2.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".
- 3.Each option is `<li role="option" id="opt-{index}">`.
- 4.The input carries `aria-expanded="true"` when the list is shown, `"false"` otherwise.
- 5.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.
- 6.The currently-highlighted option sets `aria-activedescendant` on the input to the option's id — focus never leaves the input.
- 7.Enter commits the highlighted option into the input value and closes the list. Escape closes without committing.
6 automated assertions run against your rendered component. The exact assertion names are hidden so you build to the behaviour, not to the test.
6 tests · +250 XP
Discussion
Sign in to postSign in to join the conversation.Sign in
No comments yet. Be the first to say something.