Skip to the code
cf.

Autocomplete combobox

HardDOM

The page has a text input with role="combobox" and an empty, hidden listbox for its suggestions:

<label for="fruit">Fruit</label>
<input id="fruit" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="fruit-options" autocomplete="off">
<ul id="fruit-options" role="listbox" aria-label="Fruits" hidden></ul>

Write setupCombobox(input, listbox, items), where items is an array of strings. Each time the user types (an input event), fill listbox with one <li role="option"> per item that starts with the input's text, ignoring case, in the order of items. Empty text matches every item. Give each option a unique id, and insert its text as text.

Open the list when at least one item matches, and close it when none does. Open means listbox.hidden is false and the input's aria-expanded is "true". Closed means hidden is true and aria-expanded is "false".

Focus stays on the input the whole time. The option the user has moved to is the active option: the input's aria-activedescendant holds its id, and it has aria-selected="true", which no other option has. With no active option, the input has no aria-activedescendant at all. Typing leaves no option active, and so does closing the list.

ArrowDown makes the next option active, or the first one if none is, and wraps from the last to the first. ArrowUp goes the other way, starting from the last. If the list is closed, both open it first, with the items that match the current text. Call preventDefault() for both, so the caret doesn't jump.

Enter with an active option puts that option's text in the input, closes the list, and calls preventDefault(). With no active option, Enter only closes the list. Clicking an option does what Enter does with it, and leaves focus on the input.

Escape closes the list if it's open, keeping the text, and clears the input if the list is already closed. ArrowLeft, ArrowRight, Home, and End move the caret as usual, so don't call preventDefault() for them; they leave the list open or closed as it was, with no option active. Handle the keys on keydown, reading event.key.

setupCombobox(
  document.getElementById("fruit"),
  document.getElementById("fruit-options"),
  ["Apple", "Apricot", "Banana", "Blueberry", "Cherry"],
);
// type "ap": the list opens with Apple and Apricot, and no option is active
// ArrowDown: Apple is active; ArrowDown: Apricot; ArrowDown: Apple again
// Enter: the input reads "Apple", and the list closes

What the tests check

  1. typing lists the items that start with the text, ignoring case
    const input = document.getElementById("fruit");
    const listbox = document.getElementById("fruit-options");
    const type = (text) => {
      input.value = text;
      fire(input, "input");
    };
    setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
    type("aP");
    expect([...listbox.children].map((option) => option.textContent)).toEqual(["Apple", "Apricot"]);
    expect([...listbox.children].map((option) => option.tagName + " " + option.getAttribute("role"))).toEqual(["LI option", "LI option"]);
    expect(listbox.hidden).toBe(false);
    expect(input.getAttribute("aria-expanded")).toBe("true");
    
  2. closes the list when nothing matches
    const input = document.getElementById("fruit");
    const listbox = document.getElementById("fruit-options");
    const type = (text) => {
      input.value = text;
      fire(input, "input");
    };
    setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
    type("b");
    expect(listbox.hidden).toBe(false);
    type("bx");
    expect(listbox.hidden).toBe(true);
    expect(input.getAttribute("aria-expanded")).toBe("false");
    type("bl");
    expect([...listbox.children].map((option) => option.textContent)).toEqual(["Blueberry"]);
    expect(listbox.hidden).toBe(false);
    
  3. ArrowDown opens on the first option and moves down, wrapping
    const input = document.getElementById("fruit");
    const listbox = document.getElementById("fruit-options");
    const fruits = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"];
    const press = (key) => fire(input, "keydown", { key });
    const active = () => {
      const id = input.getAttribute("aria-activedescendant");
      return id === null ? null : (document.getElementById(id)?.textContent ?? "no element with id " + id);
    };
    const selected = () => [...listbox.querySelectorAll('[aria-selected="true"]')].map((option) => option.textContent);
    setupCombobox(input, listbox, fruits);
    input.focus();
    expect(press("ArrowDown").defaultPrevented).toBe(true);
    expect(listbox.hidden).toBe(false);
    expect(input.getAttribute("aria-expanded")).toBe("true");
    expect([...listbox.children].map((option) => option.textContent)).toEqual(fruits);
    expect(active()).toBe("Apple");
    expect(selected()).toEqual(["Apple"]);
    press("ArrowDown");
    expect(active()).toBe("Apricot");
    expect(selected()).toEqual(["Apricot"]);
    for (let step = 0; step < 4; step++) press("ArrowDown");
    expect(active()).toBe("Grape");
    press("ArrowDown");
    expect(active()).toBe("Apple");
    expect(document.activeElement).toBe(input);
    
  4. ArrowUp opens on the last match and moves up, wrapping
    const input = document.getElementById("fruit");
    const listbox = document.getElementById("fruit-options");
    const press = (key) => fire(input, "keydown", { key });
    const active = () => {
      const id = input.getAttribute("aria-activedescendant");
      return id === null ? null : (document.getElementById(id)?.textContent ?? "no element with id " + id);
    };
    setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
    input.focus();
    input.value = "b";
    expect(press("ArrowUp").defaultPrevented).toBe(true);
    expect([...listbox.children].map((option) => option.textContent)).toEqual(["Banana", "Blueberry"]);
    expect(input.getAttribute("aria-expanded")).toBe("true");
    expect(active()).toBe("Blueberry");
    press("ArrowUp");
    expect(active()).toBe("Banana");
    press("ArrowUp");
    expect(active()).toBe("Blueberry");
    expect([...listbox.querySelectorAll('[aria-selected="true"]')].map((option) => option.textContent)).toEqual(["Blueberry"]);
    expect(document.activeElement).toBe(input);
    
  5. typing or moving the caret leaves no option active
    const input = document.getElementById("fruit");
    const listbox = document.getElementById("fruit-options");
    const type = (text) => {
      input.value = text;
      fire(input, "input");
    };
    const press = (key) => fire(input, "keydown", { key });
    const active = () => {
      const id = input.getAttribute("aria-activedescendant");
      return id === null ? null : (document.getElementById(id)?.textContent ?? "no element with id " + id);
    };
    const selected = () => [...listbox.querySelectorAll('[aria-selected="true"]')].map((option) => option.textContent);
    setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
    type("b");
    expect(active()).toBeNull();
    press("ArrowDown");
    expect(active()).toBe("Banana");
    type("bl");
    expect(active()).toBeNull();
    expect(selected()).toEqual([]);
    press("ArrowDown");
    expect(active()).toBe("Blueberry");
    expect(press("ArrowLeft").defaultPrevented).toBe(false);
    expect(active()).toBeNull();
    expect(selected()).toEqual([]);
    expect(listbox.hidden).toBe(false);
    press("ArrowDown");
    expect(press("End").defaultPrevented).toBe(false);
    expect(active()).toBeNull();
    
  6. Enter picks the active option, or only closes the list when none is active
    const input = document.getElementById("fruit");
    const listbox = document.getElementById("fruit-options");
    const type = (text) => {
      input.value = text;
      fire(input, "input");
    };
    const press = (key) => fire(input, "keydown", { key });
    setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
    type("b");
    press("ArrowDown");
    press("ArrowDown");
    expect(press("Enter").defaultPrevented).toBe(true);
    expect(input.value).toBe("Blueberry");
    expect(listbox.hidden).toBe(true);
    expect(input.getAttribute("aria-expanded")).toBe("false");
    expect(input.hasAttribute("aria-activedescendant")).toBe(false);
    type("ch");
    press("Enter");
    expect(input.value).toBe("ch");
    expect(listbox.hidden).toBe(true);
    expect(input.getAttribute("aria-expanded")).toBe("false");
    
  7. Escape closes the list, then clears the input
    const input = document.getElementById("fruit");
    const listbox = document.getElementById("fruit-options");
    const type = (text) => {
      input.value = text;
      fire(input, "input");
    };
    const press = (key) => fire(input, "keydown", { key });
    setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
    type("ch");
    press("ArrowDown");
    press("Escape");
    expect(listbox.hidden).toBe(true);
    expect(input.getAttribute("aria-expanded")).toBe("false");
    expect(input.hasAttribute("aria-activedescendant")).toBe(false);
    expect(input.value).toBe("ch");
    press("Escape");
    expect(input.value).toBe("");
    
  8. clicking an option picks it
    const input = document.getElementById("fruit");
    const listbox = document.getElementById("fruit-options");
    setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
    input.focus();
    input.value = "a";
    fire(input, "input");
    fire(listbox.children[1], "click");
    expect(input.value).toBe("Apricot");
    expect(listbox.hidden).toBe(true);
    expect(input.getAttribute("aria-expanded")).toBe("false");
    expect(document.activeElement).toBe(input);
    
  9. shows items as text, never as HTML
    const input = document.getElementById("fruit");
    const listbox = document.getElementById("fruit-options");
    setupCombobox(input, listbox, ["<img src=x onerror=alert(1)>", "<b>Bold</b>"]);
    input.value = "<";
    fire(input, "input");
    expect(listbox.querySelector("img, b")).toBeNull();
    expect([...listbox.children].map((option) => option.textContent)).toEqual(["<img src=x onerror=alert(1)>", "<b>Bold</b>"]);
    

Learn more

solution.jsSaved 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).