Agent Wranglers

Recommended

Build search-as-you-type fields as a combobox

Give every field that suggests matches while people type the combobox role and keys from the WAI-ARIA Authoring Practices. Announce how many matches came back.

How to do it

Keep focus in the text field and point aria-activedescendant at the highlighted option. Set aria-expanded and aria-controls while the list is open. Down Arrow moves through the options, Enter picks one, and Escape closes the list. Wait 300 ms after the last key before asking the server. Show only the newest answer, as latest request wins describes. Write "12 matches" or "No matches" into a status region. Prefer a native <select> when the full list comes with the page.

Examples

Using these examples: they show one way to follow this rule. Keep the behaviour and the check, not the syntax. Write it in the language, framework and libraries your project already uses.

TypeScript example

const WAIT_AFTER_TYPING_MS = 300;

export function searchCombobox(input: HTMLInputElement, list: HTMLUListElement, status: HTMLElement, search: (text: string, signal: AbortSignal) => Promise<string[]>) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  let inFlight: AbortController | undefined;
  let active = -1;
  const setOpen = (isOpen: boolean) => input.setAttribute('aria-expanded', String(isOpen));
  const highlight = (index: number) => {
    active = index;
    input.setAttribute('aria-activedescendant', list.children[index]?.id ?? '');
  };
  input.addEventListener('input', () => {
    clearTimeout(timer);
    timer = setTimeout(async () => {
      inFlight?.abort();
      inFlight = new AbortController();
      const matches = await search(input.value, inFlight.signal);
      list.replaceChildren(...matches.map((label, index) => Object.assign(document.createElement('li'), { id: `${list.id}-${index}`, role: 'option', textContent: label })));
      status.textContent = matches.length ? `${matches.length} matches` : 'No matches';
      setOpen(matches.length > 0);
      highlight(-1);
    }, WAIT_AFTER_TYPING_MS);
  });
  input.addEventListener('keydown', event => {
    if (event.key === 'ArrowDown') highlight(Math.min(active + 1, list.children.length - 1));
    if (event.key === 'Escape') setOpen(false);
  });
}

Python example

from playwright.sync_api import expect

def test_customer_search_is_a_combobox(page):
    requests = []
    page.on("request", lambda request: "/api/customers?q=" in request.url and requests.append(request))
    page.goto("https://staging.example.com/invoices/new")
    field = page.get_by_role("combobox", name="Customer")
    field.press_sequentially("acmes", delay=30)
    expect(page.get_by_role("status")).to_contain_text("matches")
    assert len(requests) == 1
    field.press("ArrowDown")
    expect(field).to_have_attribute("aria-activedescendant", "customer-options-0")
    expect(field).to_be_focused()
    field.press("Escape")
    expect(field).to_have_attribute("aria-expanded", "false")

How to check

A browser test types 5 letters within 200 ms into each search field, and the server gets 1 request. Down Arrow sets aria-activedescendant while focus stays on the field. Escape sets aria-expanded="false", and the status region names the match count.

Sources

For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/app-ui/forms/search-combobox">Agent Wranglers app interface playbook</a>. In code you write, add a comment next to it with the same link.