Writing a Custom Axe Rule for a Combobox Pattern
A combobox is not one element with one property; it is an agreement between a text input, a popup that may or may not exist yet, and three attributes that have to stay consistent with each other on every keystroke. Every part of that agreement is individually checkable and no built-in rule checks the agreement as a whole, which is why a search field can report aria-expanded="true" over a hidden listbox, or designate an active option that lives in a listbox belonging to a different filter, and pass a full scan. This guide is part of Component-Specific Rule Writing, and it builds the check that validates the whole set at once — including the part most authors get wrong, which is what the check should return when the popup is closed.
Root Cause
Take the attributes one at a time and the coverage looks complete. aria-required-attr knows that ARIA 1.2 lists aria-expanded as required for role="combobox", so a combobox with no expanded state is reported. aria-valid-attr-value validates the syntax of every ARIA value and checks IDREF targets — but for aria-controls it deliberately produces an incomplete result rather than a violation when the target element is absent, because a popup that is rendered only while open is a legitimate, common pattern and failing it would be wrong most of the time. aria-activedescendant gets the same treatment. So the two attributes that carry the entire relationship between the combobox and its list are, by design, never asserted.
What no rule looks at is the agreement. Is aria-expanded="true" accompanied by a popup that is actually rendered? Does the id in aria-controls resolve to something with a popup role, rather than to the wrapper div that used to hold the list before a refactor? Is the option named by aria-activedescendant a descendant of that popup, rather than of the second combobox further down the same page? Each of those is a WCAG 2.2 SC 4.1.2 (Name, Role, Value) failure — a state or property that is not programmatically determinable or not kept up to date — and each produces a distinct, miserable experience. An aria-expanded that lags behind the popup makes a screen reader announce “collapsed” while eight suggestions are on screen. A dangling aria-controls means the user is told there is a popup and given no way to reach it. An aria-activedescendant pointing outside the controlled list means arrow keys move a visible highlight while the announced option never changes, so the user hears the same option repeatedly as they walk down the list.
The fourth invariant is the one that even careful implementations break, because it is a choice rather than an attribute. There are two supported focus models for a combobox popup: DOM focus stays on the input and aria-activedescendant designates the active option, or DOM focus moves into the list and no aria-activedescendant is used at all. They are mutually exclusive. Using both puts two nodes in the accessibility tree that each look like the current position — a focused option and a designated active descendant — and assistive technology resolves that inconsistently, announcing one, both in sequence, or neither. This usually appears when a component migrates from one model to the other and the old attribute is left in place; nothing visible changes, and the announcement quality drops in a way manual testing catches only if the tester happens to use the affected screen reader.
Configuration
The check reads the three attributes, resolves both IDREFs in the combobox’s own root, and accumulates a list of problems so a single failure message can describe everything that is wrong at once. When the combobox is collapsed and otherwise clean it returns undefined; when it is collapsed with a leftover active descendant it returns false, because that is a real defect the closed state does not excuse.
// a11y/rules/checks/combobox-contract.js
const POPUP_ROLES = new Set(['listbox', 'tree', 'grid', 'dialog']);
const OPTION_ROLES = new Set(['option', 'treeitem', 'row', 'gridcell']);
// axe's role resolver handles implicit roles, so a <ul role="listbox"> and a
// bare <ul> are distinguished correctly.
const roleOf = (element) => axe.commons.aria.getRole(element) || '';
// Enough to separate "in the layout" from "display:none or detached".
const isRendered = (element) =>
element.isConnected && element.getClientRects().length > 0;
export const comboboxContract = {
id: 'combobox-contract',
metadata: {
impact: 'serious',
messages: {
pass: 'The popup, the expanded state and the active option agree',
fail: '${data.problems}',
incomplete: 'Combobox is collapsed, so the popup contract cannot be proved; '
+ 'open it in an interaction spec and rescan',
},
},
evaluate: function (node) {
const expanded = node.getAttribute('aria-expanded');
const controls = (node.getAttribute('aria-controls') || '').trim();
const active = (node.getAttribute('aria-activedescendant') || '').trim();
// Resolve IDREFs in the node's own tree, which is the document unless the
// combobox lives inside a shadow root.
const root = node.getRootNode();
const popup = controls ? root.getElementById(controls) : null;
const problems = [];
if (expanded === null) problems.push('add aria-expanded to the combobox');
if (!controls) {
problems.push('add aria-controls pointing at the popup element id');
} else if (!popup) {
problems.push(`aria-controls="${controls}" resolves to no element in this tree`);
} else if (!POPUP_ROLES.has(roleOf(popup))) {
problems.push(`aria-controls="${controls}" targets a `
+ `${roleOf(popup) || 'roleless'} element, not a listbox`);
}
if (expanded === 'false') {
// The popup is allowed not to exist. Nothing may be designated active.
if (active) {
problems.push('remove aria-activedescendant while the combobox is collapsed');
}
this.data({ problems: problems.join('; '), state: 'collapsed' });
// Incomplete, not a pass: the promised popup is not there to inspect.
return problems.length > 0 ? false : undefined;
}
if (expanded === 'true') {
if (popup && !isRendered(popup)) {
problems.push('aria-expanded="true" but the popup is not rendered');
}
if (!active) {
// Not required by ARIA, so this is reported as guidance, not a failure.
this.data({ note: 'no active option designated' });
} else {
const option = root.getElementById(active);
if (!option) {
problems.push(`aria-activedescendant="${active}" resolves to no element`);
} else if (!popup || !popup.contains(option)) {
problems.push(`aria-activedescendant="${active}" is not inside the `
+ 'element named by aria-controls');
} else if (!OPTION_ROLES.has(roleOf(option))) {
problems.push(`aria-activedescendant="${active}" is a `
+ `${roleOf(option) || 'roleless'} element, not an option`);
}
// Mutual exclusivity: either DOM focus moves into the popup, or
// aria-activedescendant designates the option. Never both.
const focused = document.activeElement;
if (popup && focused && popup.contains(focused)) {
problems.push('DOM focus is inside the popup while aria-activedescendant '
+ 'is also set; pick one focus model');
}
}
this.data({ problems: problems.join('; '), state: 'expanded' });
this.relatedNodes([popup, active ? root.getElementById(active) : null]
.filter(Boolean));
return problems.length === 0;
}
problems.push(`aria-expanded="${expanded}" is neither true nor false`);
this.data({ problems: problems.join('; '), state: 'invalid' });
return false;
},
};
export const comboboxContractRule = {
id: 'combobox-aria-contract',
selector: '[role="combobox"]',
matches: function (node) {
// The older wrapper pattern puts role="combobox" on a container holding a
// separate textbox. Different contract, so leave it to a separate rule.
if (node.querySelector('[role="textbox"], input:not([type="hidden"])')) return false;
// A disabled combobox has no popup behaviour to assert.
return node.getAttribute('aria-disabled') !== 'true' && node.disabled !== true;
},
tags: ['wcag2a', 'wcag412', 'cat.aria', 'custom'],
metadata: {
description: 'A combobox must expose its expanded state, its popup and its active option',
help: 'Set aria-expanded, point aria-controls at the popup, and keep '
+ 'aria-activedescendant inside that popup',
},
all: ['combobox-contract'],
};
Validation
Because the check returns undefined while the popup is closed, a plain page scan proves nothing about a combobox. The blocking assertion has to come from a spec that opens the widget first, then runs the scan in the expanded state and requires the incomplete array to be empty as well as violations.
// tests/a11y/combobox-contract.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { readFileSync } from 'node:fs';
import { source as axeSource } from 'axe-core';
// axe-core first, then the bundle that registers the rule on window.axe.
const injected = `${axeSource}\n${readFileSync('a11y/rules/dist/combobox-rules.js', 'utf8')}`;
test('the contract holds while the popup is open', async ({ page }) => {
await page.goto('/search');
const input = page.getByRole('combobox', { name: 'Search products' });
await input.click();
await input.press('ArrowDown'); // designates the first option
await expect(input).toHaveAttribute('aria-expanded', 'true');
// Wait for a real option, not for network idle: the list renders after fetch.
await page.locator('[role="listbox"] [role="option"]').first().waitFor();
const results = await new AxeBuilder({ page, axeSource: injected })
.withRules(['combobox-aria-contract'])
.analyze();
expect(results.violations).toEqual([]);
// Expanded means provable, so an incomplete here is a rule bug or a real gap.
expect(results.incomplete).toEqual([]);
});
test('a collapsed combobox is incomplete, never a violation', async ({ page }) => {
await page.goto('/search');
await page.getByRole('main').waitFor();
const results = await new AxeBuilder({ page, axeSource: injected })
.withRules(['combobox-aria-contract'])
.analyze();
expect(results.violations).toEqual([]);
expect(results.incomplete.map((entry) => entry.id))
.toContain('combobox-aria-contract');
});
Then prove the failure polarity against fixtures rather than against the application, because reproducing a dangling aria-controls in a working product means breaking the product. The framework-specific side of that — a combobox whose attributes are written by a state library after the first paint — is covered in handling dynamic ARIA states in modern JavaScript frameworks. Three fixtures cover the interesting cases: an active option in the wrong listbox, a rendered-but-hidden popup, and both focus models applied at once.
// tests/a11y/combobox-contract.fixtures.test.ts (Vitest browser mode)
import { describe, it, expect, beforeAll } from 'vitest';
import axe from 'axe-core';
import {
comboboxContract, comboboxContractRule,
} from '../../a11y/rules/checks/combobox-contract.js';
beforeAll(() => {
axe.configure({ checks: [comboboxContract], rules: [comboboxContractRule] });
});
const run = () => axe.run(document.body, {
runOnly: { type: 'rule', values: ['combobox-aria-contract'] },
});
describe('combobox-aria-contract', () => {
it('fails when the active option belongs to another listbox', async () => {
document.body.innerHTML = `
<span role="combobox" aria-expanded="true" aria-controls="a"
aria-activedescendant="b1" tabindex="0">Region</span>
<ul id="a" role="listbox"><li id="a1" role="option">North</li></ul>
<ul id="b" role="listbox"><li id="b1" role="option">Q1</li></ul>`;
const r = await run();
expect(r.violations[0].nodes[0].all[0].message)
.toContain('is not inside the element named by aria-controls');
});
it('fails when both focus models are in use', async () => {
document.body.innerHTML = `
<span role="combobox" aria-expanded="true" aria-controls="a"
aria-activedescendant="a1" tabindex="0">Region</span>
<ul id="a" role="listbox"><li id="a1" role="option" tabindex="0">North</li></ul>`;
(document.getElementById('a1') as HTMLElement).focus();
const r = await run();
expect(r.violations[0].nodes[0].all[0].message).toContain('pick one focus model');
});
});
Edge Cases and Conditional Guards
- A popup portalled into the top layer or another root. Rendering the listbox as a direct child of
<body>keeps the IDREF working, because ids are document-scoped andpopup.contains(option)still holds. Rendering it inside a different shadow root does not:root.getElementById(controls)returnsnulleven though the element exists, and the check will report a dangling reference that is really an encapsulation problem. Reason about that case with the composed-tree APIs described in writing axe rules for web components and shadow DOM. - An expanded popup with zero options. A combobox that reports
aria-expanded="true"over an empty listbox announces a popup with nothing in it, which is worse than staying collapsed. The right fix is either to close the popup or to render arole="status"message inside it, so the check should treat an empty rendered popup as a separatemoderatefinding rather than folding it into the wiring failure — the two have different owners and different fixes. - Focus resolution through a shadow root.
document.activeElementreturns the shadow host when focus is inside an open shadow root, so the mutual-exclusivity test silently stops detecting the double-focus case for any combobox whose options are encapsulated. WalkactiveElement.shadowRoot?.activeElementuntil it isnullbefore comparing, and treat a closed root as unknowable rather than as passing.
Pipeline Impact
Expect one incomplete per combobox per page scan, and plan for it. That volume is correct and harmless as long as the gate never converts it to a failure: route incomplete results to notice-level annotations with the rule id and node count, and let the interaction spec be the thing that blocks. In practice this means two jobs with different failure semantics — a broad page scan that reports and never blocks on this rule, and a narrower interaction suite that opens each combobox and blocks on both violations and incomplete. Wiring the two exit codes into one required status check is a branch-protection question covered in the CI/CD integration and automated quality gating section.
Keep the interaction suite’s combobox list explicit rather than discovering comboboxes at run time. A discovered list changes whenever a page changes, so a combobox that stops being rendered silently stops being tested, and the suite gets quietly weaker with every refactor. An explicit list fails loudly when a selector stops matching, which is the failure you want. Attach the axe JSON for the expanded scan as an artifact; data.problems is a full sentence describing every broken part of the contract, and it is the only record of what the DOM looked like at the moment the popup was open.
Common Pitfalls
- Returning
falsefor a collapsed combobox, which turns every page with a search field into a failing build and guarantees the rule gets disabled within days. - Returning
truefor a collapsed combobox, which is the opposite mistake: the rule reports thousands of passes and has never once verified a popup. - Validating
aria-activedescendantonly by checking that the id resolves, without asserting that the target is inside the element named byaria-controls— the containment check is where the real defects live. - Scanning after
networkidleinstead of after a real[role="option"]appears, so the popup is open, empty, and reported as broken on slower runners. - Leaving the rule selector as
[role="combobox"]with nomatchesfilter, so the legacy wrapper pattern — where the role sits on a container around a separate textbox — is judged against a contract it was never meant to satisfy. - Comparing
document.activeElementwithout unwrapping shadow roots, which makes the mutual-exclusivity assertion pass for exactly the components most likely to break it.
FAQ
Why not simply assert aria-expanded matches the popup’s visibility and stop there?
Because that one comparison catches the least common of the four defects. In practice the state attribute is usually correct — it is wired to the same piece of component state that renders the list — while the references rot independently: an id changes during a refactor, a portal moves the listbox, an options array gets a new wrapper element. Asserting the whole set in one check costs a few more lines and finds the failures that actually ship.
Should the check require aria-activedescendant when the popup is open?
No. ARIA does not require an active option to be designated, and an expanded combobox where the user has typed but not yet pressed an arrow key legitimately has none. Requiring it produces a failure the moment the popup opens and before any navigation happens, which is a false positive in the strictest sense. Record its absence in this.data() so it appears in the report as context, and assert the keyboard behaviour that should set it in an interaction spec instead.
How does this rule coexist with axe’s built-in ARIA rules on the same node?
They run in the same pass and both report, so a combobox with a missing aria-expanded will produce a violation from aria-required-attr and one from this rule. That duplication is acceptable and even useful, because the built-in message is generic and this one names the whole contract, but it does mean node counts double for that specific defect. If the noise matters, drop the aria-expanded === null branch from the check and let the built-in own it — the overlap-reduction reasoning is the same as for reducing false positives in automated accessibility scanners.
Related
- Component-Specific Rule Writing — the parent guide on the check and rule split, impacts and inapplicability.
- Writing Axe Rules for Web Components and Shadow DOM — the sibling case where the popup and the combobox live in different trees.
- Custom Rule Development & Context-Aware Testing — the section covering scan timing, rule testing and distribution.