Custom combobox bugs usually are not about whether the menu opens. They show up when focus moves in the wrong place, Escape closes the wrong thing, arrow keys stop working after a rerender, or a screen reader user lands on an option the app no longer thinks is active.

If you need to test custom combobox keyboard behavior, start with one simple rule: treat the input, the popup, and the active option as three separate states. A lot of regressions happen when a component copies one of those states into the wrong DOM node, or when a framework rerender drops the state entirely.

The most expensive failures are the ones where the widget still looks usable to sighted mouse users, but keyboard and assistive technology users are stranded.

This article focuses on custom selects, autocomplete fields, and typeahead menus built with ARIA patterns. It covers what to verify, why the bugs happen, and how to automate the checks without making your tests brittle.

The three things people mix up

Before writing tests, separate these concepts:

  • DOM focus, the element that receives keyboard events.
  • Visual active item, the option highlighted in the popup.
  • Accessible active item, the option exposed to assistive tech, often through aria-activedescendant.

These can be the same element in a simple widget, but they do not have to be. With aria-activedescendant, focus stays on the textbox or combobox input while the currently highlighted option is referenced by ID. The WAI-ARIA Authoring Practices describe this pattern for comboboxes and listbox-based autocomplete widgets, and WCAG requirements around keyboard access and focus visibility still apply. See the WCAG standards page and the WAI-ARIA APG combobox pattern for the canonical behavior model.

That distinction drives the test strategy:

  • verify the focused element,
  • verify the active descendant or option highlight,
  • verify the popup state separately.

What a custom combobox must do

For a typical text-entry combobox or typeahead menu, the minimum keyboard contract is usually:

  • typing filters results,
  • ArrowDown and ArrowUp move the active option,
  • Enter selects the active option,
  • Escape closes the popup without losing the typed value unless the design says otherwise,
  • Tab leaves the widget predictably,
  • focus remains visible and recoverable after selection or dismissal.

If the component uses aria-activedescendant, the input keeps focus while the active option changes by reference. If it uses roving tabindex instead, focus moves onto the options themselves. Do not test these as interchangeable patterns, because they fail differently.

Failure modes worth checking first

  1. Arrow keys scroll the page instead of moving the option. This often means the widget did not call preventDefault() when it should.

  2. The popup closes on ArrowUp or ArrowDown. This usually means the component does not distinguish closed state from open state.

  3. The active option disappears after filtering. A rerender can remove the previously highlighted node, leaving aria-activedescendant pointing to a stale ID.

  4. Escape clears the input when it should only close the menu. That is a product decision, but it needs a test either way.

  5. Tab order becomes trapped inside the listbox. This is one of the easiest ways to break keyboard navigation while still passing mouse checks.

A practical checklist for keyboard behavior

Use this checklist on every custom combobox, then adapt it to your product rules.

1) Closed state

  • The input can receive focus with Tab.
  • The popup is not exposed as open before interaction.
  • Placeholder and label text are clear enough to explain the field without the menu.

2) Open state

  • ArrowDown opens the popup if the pattern supports that behavior.
  • Typing opens or updates the suggestions list.
  • The active option is visible and unambiguous.
  • The component does not steal focus away from the input unless the design intentionally uses roving tabindex.

3) Navigation state

  • ArrowDown and ArrowUp move one option at a time.
  • The highlighted option stays synchronized with the accessibility state.
  • Home/End behavior, if implemented, is documented and tested.
  • Page scroll does not move when the widget is active.

4) Selection state

  • Enter selects the highlighted option.
  • Clicking an option and pressing Enter produce the same committed value.
  • The popup closes or stays open according to the product rule, not by accident.
  • The selected value is visible after blur or rerender.

5) Dismissal state

  • Escape closes the popup.
  • Escape does not leave focus in a dead state.
  • Tab exits the widget cleanly.
  • Shift+Tab reverses out of the widget without re-opening it.

6) Re-render state

  • Filtering, virtualization, or async loading does not reset the active item unexpectedly.
  • The active descendant ID still exists after the list updates.
  • The component preserves focus after network or state updates.

How to test aria-activedescendant without guessing

aria-activedescendant testing is mostly about verifying identity, not just appearance. The input should keep focus, and the attribute should point to the active option’s ID.

A useful minimal assertion set is:

  • the input remains focused,
  • aria-activedescendant changes on arrow navigation,
  • the referenced element exists,
  • the referenced element has the expected label or text,
  • the popup remains open while navigation continues.

Here is a compact Playwright example:

import { test, expect } from '@playwright/test';
test('moves aria-activedescendant with arrow keys', async ({ page }) => {
  const input = page.getByRole('combobox', { name: /country/i });

  await input.click();
  await input.press('ArrowDown');

  await expect(input).toBeFocused();
  const activeId = await input.getAttribute('aria-activedescendant');
  expect(activeId).toBeTruthy();

  const activeOption = page.locator(`#${activeId}`);
  await expect(activeOption).toBeVisible();
});

Two things matter here. First, the test checks the focused element directly. Second, it resolves the active descendant ID back to a real DOM node. That catches stale IDs after rerenders.

Add a stale-ID guard

If the widget filters options asynchronously, verify that the active descendant remains valid after input changes:

await input.fill('un');
await expect(page.locator('[role="option"]')).toHaveCountGreaterThan(0);
const activeId = await input.getAttribute('aria-activedescendant');
expect(activeId).toBeTruthy();
await expect(page.locator(`#${activeId}`)).toHaveText(/un/i);

If the app virtualizes options, do not assume the option must remain in the DOM forever. In that case, assert the active option is in the rendered window, not that every filtered result is present at once.

Catching focus traps and lost focus

Focus bugs are easier to miss than selection bugs because the UI can still look responsive. Test them explicitly.

The checks that matter

  • The input still has focus after opening the list.
  • Selecting an item does not move focus to a hidden or removed node.
  • Escape returns to a stable state, not a blank focus ring.
  • Tabbing away leaves the widget without reopening the popup.

A simple focus-trap test in Playwright might look like this:

await input.click();
await input.press('ArrowDown');
await input.press('Escape');
await expect(input).toBeFocused();
await expect(page.getByRole('listbox')).toBeHidden();

If the spec says Escape should return focus to the trigger button instead of the input, update the assertion to match the intended interaction model. The important part is not the exact destination, it is that the widget does not strand keyboard users.

Testing arrow-key regressions the right way

Arrow-key bugs often appear after small refactors, because the component still renders but the event path changed. That means your tests should verify both the visual state and the event contract.

What to assert

  • Arrow keys do not submit forms accidentally.
  • Arrow keys do not select text inside the input unless that is the intended behavior.
  • The active option advances one step at a time.
  • The highlighted option scrolls into view when needed.

A regression check for wrap behavior is useful if your design supports it:

await input.press('ArrowDown');
await input.press('ArrowDown');
await input.press('ArrowUp');
const activeId = await input.getAttribute('aria-activedescendant');
expect(activeId).toBeTruthy();

Do not over-specify the exact option order unless the product promises a stable sort. For dynamic data, it is safer to assert relative movement and final selection than a brittle full list sequence.

Typeahead accessibility is more than filtering

Typeahead accessibility means a user can type, understand what changed, and finish the task without guessing. Filtering alone is not enough.

Check these points:

  • The current query remains visible in the input.
  • The suggestion count or result state is discoverable.
  • No-results states are announced or otherwise visible.
  • If matches update asynchronously, the UI does not jump focus unexpectedly.

If your component shows “No results” after typing, test that it is not treated as an option. A common bug is to leave a no-results row focusable, which confuses keyboard navigation and assistive technologies.

What to automate, and what to inspect manually

Automate the deterministic behavior first:

  • focus order,
  • open and close behavior,
  • arrow-key movement,
  • selection,
  • stale active-descendant IDs,
  • no-results and disabled-option states.

Inspect manually when the widget depends on speech output, screen-reader announcement timing, or highly dynamic layout changes. Automated tests can tell you that the active descendant changed. They cannot reliably tell you whether a screen reader user heard the right announcement in the right context.

If the bug report mentions “it sounded wrong” or “I lost track of where I was,” that is a signal to add an assistive-tech review, not just another DOM assertion.

A small rubric for deciding test depth

Use this quick decision framework when you are deciding how much to test:

Situation Focus on Why
Static listbox with few options Keyboard order, selection, blur Low state complexity
Typeahead with async search Stale IDs, loading states, no-results handling Rerenders create focus bugs
Virtualized results Visible window, active descendant validity Offscreen options may not exist in DOM
Form inside modal Escape, Tab, nested focus traps Multiple focus scopes can conflict
Mobile app shell or hybrid UI Touch plus hardware keyboard paths Input behavior can diverge by device

If the widget is simple and static, basic keyboard tests may be enough. If it fetches results, virtualizes items, or lives inside a modal or popover system, you need more explicit state assertions.

Not the best fit if…

This approach is not the best fit if your component is a native <select>. Native controls already have browser-managed keyboard behavior, and your testing should shift toward label association, form submission, and cross-browser rendering rather than re-implementing combobox rules.

It is also not enough if your product must be fully verified with a screen reader. For that, add manual checks in at least one supported browser and assistive-tech combination.

A compact takeaway

The safest way to test custom combobox keyboard behavior is to assert three things independently: focus, popup state, and active option identity. Once you do that, most regressions become obvious, including stale aria-activedescendant references, arrow-key failures, accidental focus traps, and broken Escape handling.

If you remember only one rule, make it this: do not trust the highlight alone. In accessible autocomplete testing, the highlight is only meaningful when focus and accessibility state agree.

FAQ

How do I know whether a widget should use aria-activedescendant or roving tabindex?

Use the pattern the component is designed for, then test against that contract. aria-activedescendant keeps DOM focus on the textbox or combobox input, while roving tabindex moves focus among options. Do not mix the assertions.

What is the most common keyboard regression in custom selects?

Arrow keys either stop moving the active option or start scrolling the page. Both indicate the widget is not handling keyboard events correctly when open.

Should Escape clear the input value?

Only if the product design says so. The important thing is consistency, not a universal rule. Test the intended behavior explicitly.

How do I test async autocomplete without flakiness?

Wait for the rendered options or the loading state to settle before asserting the active item. Then verify that the active descendant ID still points to a live element after the result update.

Can I rely on visual snapshots for combobox testing?

Not by themselves. Snapshots can show the popup, but they do not prove focus behavior, active-descendant integrity, or keyboard navigation.