Accessibility · Testing Accessibility
A Sequential Focus Test Protocol
How to record and verify tab order, focus visibility, and focus containment, and what tabindex values, inert subtrees, and dialogs do to the sequence.
Focus order is a sequence, and a sequence is testable. The test is unglamorous: start at the top of the document, press Tab repeatedly, and record what has focus after each press. The recording is the artifact. Everything else, whether the order is sensible, whether focus is visible, whether it escapes a dialog, is read off that recording.
Doing this by hand is slow enough that it gets skipped, and doing it badly produces results that cannot be reproduced. A protocol fixes both problems: it defines the starting condition, the key sequence, the state captured at each step, and the pass conditions.
The record and its columns
The minimum viable record for each step is four fields: the step index, the accessible name and role of the focused element, its bounding box or a stable identifier, and a screenshot or a computed-style snapshot of the focus indicator. The first two identify what the user is told; the third identifies where it is on screen; the fourth identifies whether they can see it.
Names and roles come from the accessibility tree rather than from markup, for the same reason assertions do: the tree is what the user receives. An element whose DOM id is btn-4 may be announced as “Remove item, button”, and the record should say the latter.
The bounding box is the part usually omitted and the part that finds real defects. A focused element whose box is entirely outside the viewport, or whose computed opacity is zero, or which is covered by a sticky header, is focused and not perceivable. The screenshot column catches it; the box column documents it.
What enters the sequence
The set of focusable elements is defined by the HTML standard, and the definition is more conditional than a selector can express. In the default sequential navigation order, an element is reachable by Tab when it is focusable and not removed from sequential navigation. Focusable means, broadly: <a> and <area> with an href, form controls that are not disabled, <summary> inside a <details>, <iframe>, and any element with a tabindex value. Removed from sequential navigation means a negative tabindex, which leaves the element focusable by script and by click but skipped by Tab.
The conditions matter. <a> without an href is not focusable and does not enter the sequence, which is the mechanism behind the common defect of a link-shaped control that does nothing on Enter. A <button> with the disabled attribute is not focusable; the same button with aria-disabled="true" is focusable and remains in the sequence, which is the intended difference between the two and a difference a static scan cannot verify. An element inside a subtree with the inert attribute is removed from the sequence entirely, along with its descendants.
Order is determined by a combination of tabindex and document order. Positive tabindex values create a separate ordering that runs before all zero-valued and default elements, sorted by value; elements with tabindex="0" and elements focusable by default follow in tree order. Because positive values are global to the document and interact badly with content inserted later, they are the usual cause of an order that does not match the visual layout. The sequence record exposes this immediately: the recorded order will jump between distant regions.
// Drive Tab and log what the browser considers focused after each press.
const seen = [];
for (let i = 0; i < 60; i++) {
await page.keyboard.press('Tab');
const step = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null; // focus left the document
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
tag: el.tagName.toLowerCase(),
id: el.id || null,
name: el.getAttribute('aria-label') || el.textContent.trim().slice(0, 40),
box: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
visible: cs.visibility !== 'hidden' && cs.display !== 'none' && r.width > 0,
inViewport: r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth,
};
});
if (step === null) break;
seen.push(step);
}
The loop terminates when focus reaches the browser chrome and document.activeElement becomes the body, which is the signal that the sequence has left the page. Capturing a fixed number of steps instead of detecting the end risks recording the same wraparound twice.
Pass conditions worth asserting
Four properties are checkable from the record, and each corresponds to a failure mode with a known cause.
Completeness: every interactive control that a user can see is in the sequence, and no non-interactive element is. A missing control means it is not focusable: no href on a link, tabindex="-1" applied to something meant to be reachable, or a <div> with a click handler and no role. An extra entry means something focusable was left in the DOM: a hidden input that is still displayed, a control inside a region that should have been inert, a skip link that is visible when it should only appear on focus.
Reachability: the sequence does not trap. A trap is a cycle: the same ordered set of elements repeats and the rest of the page is never reached. The cause is either a script that intercepts Tab and wraps focus inside a container, or a focusable element that is re-created on focus. The record shows the cycle as a repeated suffix, and detecting it is a string-match over the identifier column.
Visibility: the focus indicator is perceivable at each step. The check is a comparison of the element’s computed style before and after focus, covering outline width, outline colour, and box-shadow, plus a geometric check that the indicator is not clipped by an ancestor with overflow: hidden. Relying on the browser default is permitted; the default is a visible outline, and the failure mode is a stylesheet that removes it without replacing it.
Order: the sequence matches the reading order and the visual order. This is the one condition that needs a human judgement, because “sensible” depends on the layout. A two-column form whose visual left-to-right order differs from its source order produces a sequence that zigzags, and only a person comparing the record against the layout can say whether that is a defect or an acceptable consequence of a responsive rearrangement.
Dialogs, popups, and containment
Modal behaviour is where the protocol earns its keep, because it is the case where the correct sequence is not a prefix of the static one. When a dialog opens, the correct sequence is: focus moves into the dialog, tabbing cycles within the dialog, Escape closes it, and focus returns to the element that opened it.
The record for this test has three segments, and each has a distinct assertion. On open, the first focused element must be inside the dialog subtree, and typically is either the dialog container itself, marked focusable, or the first interactive control. During traversal, the sequence must never leave the dialog: no step may land on an element outside it. On close, the focused element after the close must be the opener, identified by name and role rather than by position.
There are three mechanisms for containment and they behave differently under test. The inert attribute removes a subtree from focus and from the tree, and is the cleanest because it needs no script. A native <dialog> element opened with showModal() gets focus containment and Escape handling from the browser. A scripted trap that listens for Tab and redirects focus when it would leave works only while the listener is attached and only if it handles the shift-Tab direction as well; a trap that handles only forward Tab is a common and easily recorded defect, because shift-Tab from the first control escapes to the browser chrome.
The record distinguishes these without needing to know which was used: the inert and native cases produce a sequence that never leaves, and the scripted case produces a sequence that leaves in one direction. A test that asserts only forward traversal will pass the broken trap.
Automating the protocol honestly
The sequence test is automatable, and the automation has one limitation worth stating: the browser’s own notion of focusability is the oracle, and it is reachable through document.activeElement after a real key event. Synthetic events dispatched by script do not move focus, because the browser’s default action for Tab is not triggered by a dispatched KeyboardEvent. Automation that uses a real input channel, which Playwright and WebDriver both provide, gets the true behaviour.
The other limitation is coverage. A sequence test covers the paths it walks; a page with a menu that opens into twenty links has twenty sequences, and the protocol needs a decision about which to record. The usual choice is the primary flow plus each region with a distinct containment rule, with the interaction coverage argument supplying the justification for what is skipped.
What the protocol produces is a document that can be diffed. A refactor that changes the order, removes a control from the sequence, or breaks a trap changes the record, and the diff is the review artifact. That property, more than any individual assertion, is what makes focus order maintainable rather than a thing that gets checked once before launch. For composite widgets, the same record becomes part of a larger contract test, where each arrow key and each wrap at the boundary is asserted separately.