For AI agents: llms.txt is a short guide that sends you to the rules your problem needs, and rules.json holds every rule of this playbook as JSON.
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
- ARIA APG combobox pattern
- WCAG 2.2 criterion 4.1.2 Name, Role, Value
- WCAG 2.2 criterion 4.1.3 Status Messages
Related rules
- Show the answer to the latest request only in App interface
- Announce changes without moving focus in Website
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.