Skip to the code
cf.

Autocomplete

The tea shop's order form has a box where you start typing a tea's name and pick it from a list. Build App: it takes options (a list of strings), label (the box's label), and onSelect. When they aren't given, as in the Preview, use a few tea names of your own, the label Tea, and an onSelect that logs the pick.

Render a <label> and a text <input role="combobox" aria-autocomplete="list"> it labels. As you type, show the options that contain the text, ignoring case and the spaces around it, in their original order: a <ul role="listbox"> with one <li role="option"> each. When the box is empty (or only spaces), there's no list. When the text matches nothing, show No matches instead of the list.

The input's aria-expanded is "true" while the listbox is showing and "false" otherwise (also while No matches shows). While it shows, the input's aria-controls is the listbox's id.

The focus always stays in the box. ArrowDown and ArrowUp move a highlight through the options instead: the highlighted option has aria-selected="true" (the others "false"), and the input's aria-activedescendant is that option's id. With nothing highlighted, the input has no aria-activedescendant. ArrowDown from nothing goes to the first option and ArrowUp to the last; both wrap around at the ends. While no list shows, the arrows do nothing.

Typing clears the highlight. Enter picks the highlighted option: the box's text becomes the option, the list closes, and onSelect is called with it. Enter with nothing highlighted does nothing. Clicking an option picks it the same way, and the focus stays in the box. Escape closes the list but keeps the text; typing again opens it.

<App label="Tea" options={["Earl Grey", "Green", "Sencha"]} onSelect={(tea) => addToOrder(tea)} />
 
// after typing "gr" and pressing ArrowDown:
<label for="tea">Tea</label>
<input id="tea" role="combobox" aria-autocomplete="list" aria-expanded="true"
  aria-controls="tea-list" aria-activedescendant="tea-option-0" value="gr" />
<ul id="tea-list" role="listbox">
  <li id="tea-option-0" role="option" aria-selected="true">Earl Grey</li>
  <li id="tea-option-1" role="option" aria-selected="false">Green</li>
</ul>

What the tests check

  1. renders a labelled combobox with no list at first
    const teas = ["Assam", "Darjeeling", "Earl Grey", "Genmaicha", "Green", "Jasmine", "Oolong", "Rooibos", "Sencha"];
    const onSelect = spy();
    render(<App options={teas} label="Tea" onSelect={onSelect} />);
    const box = screen.getByRole("combobox", { name: "Tea" });
    expect(box).toHaveAttribute("aria-autocomplete", "list");
    expect(box).toHaveAttribute("aria-expanded", "false");
    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
    
  2. typing shows the options that contain the text, ignoring case
    const teas = ["Assam", "Darjeeling", "Earl Grey", "Genmaicha", "Green", "Jasmine", "Oolong", "Rooibos", "Sencha"];
    const onSelect = spy();
    render(<App options={teas} label="Tea" onSelect={onSelect} />);
    const box = screen.getByRole("combobox", { name: "Tea" });
    await user.type(box, "GR");
    const list = screen.getByRole("listbox");
    expect(within(list).getAllByRole("option").map((option) => option.textContent)).toEqual(["Earl Grey", "Green"]);
    expect(box).toHaveAttribute("aria-expanded", "true");
    expect(box).toHaveAttribute("aria-controls", list.id);
    await user.clear(box);
    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
    
  3. shows No matches when nothing matches
    const teas = ["Assam", "Darjeeling", "Earl Grey", "Genmaicha", "Green", "Jasmine", "Oolong", "Rooibos", "Sencha"];
    const onSelect = spy();
    render(<App options={teas} label="Tea" onSelect={onSelect} />);
    const box = screen.getByRole("combobox", { name: "Tea" });
    await user.type(box, "xyz");
    expect(screen.getByText("No matches")).toBeInTheDocument();
    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
    expect(box).toHaveAttribute("aria-expanded", "false");
    
  4. ArrowDown highlights the first option through aria-activedescendant, with the focus left in the box
    const teas = ["Assam", "Darjeeling", "Earl Grey", "Genmaicha", "Green", "Jasmine", "Oolong", "Rooibos", "Sencha"];
    const onSelect = spy();
    render(<App options={teas} label="Tea" onSelect={onSelect} />);
    const box = screen.getByRole("combobox", { name: "Tea" });
    await user.type(box, "en");
    expect(box).not.toHaveAttribute("aria-activedescendant");
    await user.keyboard("{ArrowDown}");
    const first = screen.getByRole("option", { name: "Genmaicha" });
    expect(first.id).toBeTruthy();
    expect(box).toHaveAttribute("aria-activedescendant", first.id);
    expect(first).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("option", { name: "Green" })).toHaveAttribute("aria-selected", "false");
    expect(box).toHaveFocus();
    
  5. the arrow keys wrap around
    const teas = ["Assam", "Darjeeling", "Earl Grey", "Genmaicha", "Green", "Jasmine", "Oolong", "Rooibos", "Sencha"];
    const onSelect = spy();
    render(<App options={teas} label="Tea" onSelect={onSelect} />);
    const box = screen.getByRole("combobox", { name: "Tea" });
    await user.type(box, "en");
    await user.keyboard("{ArrowUp}");
    expect(box).toHaveAttribute("aria-activedescendant", screen.getByRole("option", { name: "Sencha" }).id);
    await user.keyboard("{ArrowDown}");
    expect(box).toHaveAttribute("aria-activedescendant", screen.getByRole("option", { name: "Genmaicha" }).id);
    await user.keyboard("{ArrowDown}");
    expect(box).toHaveAttribute("aria-activedescendant", screen.getByRole("option", { name: "Green" }).id);
    
  6. Enter picks the highlighted option
    const teas = ["Assam", "Darjeeling", "Earl Grey", "Genmaicha", "Green", "Jasmine", "Oolong", "Rooibos", "Sencha"];
    const onSelect = spy();
    render(<App options={teas} label="Tea" onSelect={onSelect} />);
    const box = screen.getByRole("combobox", { name: "Tea" });
    await user.type(box, "en");
    await user.keyboard("{ArrowDown}{ArrowDown}{Enter}");
    expect(box).toHaveValue("Green");
    expect(onSelect).toHaveBeenCalledTimes(1);
    expect(onSelect).toHaveBeenCalledWith("Green");
    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
    expect(box).toHaveAttribute("aria-expanded", "false");
    expect(box).not.toHaveAttribute("aria-activedescendant");
    
  7. Enter with nothing highlighted does nothing
    const teas = ["Assam", "Darjeeling", "Earl Grey", "Genmaicha", "Green", "Jasmine", "Oolong", "Rooibos", "Sencha"];
    const onSelect = spy();
    render(<App options={teas} label="Tea" onSelect={onSelect} />);
    const box = screen.getByRole("combobox", { name: "Tea" });
    await user.type(box, "en{Enter}");
    expect(onSelect).not.toHaveBeenCalled();
    expect(box).toHaveValue("en");
    expect(screen.getByRole("listbox")).toBeInTheDocument();
    
  8. clicking an option picks it and keeps the focus in the box
    const teas = ["Assam", "Darjeeling", "Earl Grey", "Genmaicha", "Green", "Jasmine", "Oolong", "Rooibos", "Sencha"];
    const onSelect = spy();
    render(<App options={teas} label="Tea" onSelect={onSelect} />);
    const box = screen.getByRole("combobox", { name: "Tea" });
    await user.type(box, "s");
    await user.click(screen.getByRole("option", { name: "Sencha" }));
    expect(box).toHaveValue("Sencha");
    expect(onSelect).toHaveBeenCalledWith("Sencha");
    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
    expect(box).toHaveFocus();
    
  9. Escape closes the list and keeps the text, and typing opens it again
    const teas = ["Assam", "Darjeeling", "Earl Grey", "Genmaicha", "Green", "Jasmine", "Oolong", "Rooibos", "Sencha"];
    const onSelect = spy();
    render(<App options={teas} label="Tea" onSelect={onSelect} />);
    const box = screen.getByRole("combobox", { name: "Tea" });
    await user.type(box, "en");
    await user.keyboard("{Escape}");
    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
    expect(box).toHaveValue("en");
    expect(box).toHaveAttribute("aria-expanded", "false");
    await user.type(box, "c");
    expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual(["Sencha"]);
    
  10. typing clears the highlight
    const teas = ["Assam", "Darjeeling", "Earl Grey", "Genmaicha", "Green", "Jasmine", "Oolong", "Rooibos", "Sencha"];
    const onSelect = spy();
    render(<App options={teas} label="Tea" onSelect={onSelect} />);
    const box = screen.getByRole("combobox", { name: "Tea" });
    await user.type(box, "e");
    await user.keyboard("{ArrowDown}{ArrowDown}");
    await user.type(box, "n");
    expect(box).not.toHaveAttribute("aria-activedescendant");
    expect(screen.getAllByRole("option").every((option) => option.getAttribute("aria-selected") === "false")).toBe(true);
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).