Lesson 22 / 27
User-Centric Queries
Turning the accessible role and accessible name into a query criterion, querying the same document by role and by class selector, and how the two criteria behave under refactoring and an accessibility regression.
Contents
The previous lesson showed that a behavior-dependent test is more resilient than an implementation-detail-dependent one, but it still looked at the tree’s choice of tag to find the element under test. A selector based on a class name is the same kind of dependency: both depend on something the author should be free to change.
The user does not find the element this way. A user working with a mouse looks for a button that reads “Save”; a user working with a screen reader reaches the element announced as “Save, button”. What the two have in common is two pieces of information: the element’s role and accessible name. This lesson turns those two pieces of information into a query criterion.
The Source of the Criterion
The Web Fundamentals and HTML course defined the accessibility tree: a second tree the browser derives from the document tree and presents to assistive technology. Every node in this tree has a role, and most have a name. The role says what the element is (button, link, search box, row), the name says which one.
Querying by these two pieces of information has two consequences for the test. The first is resilience: class names, wrapper containers, and styling architecture decisions do not change the role. The second, and more important, is that the query itself becomes an accessibility check. A role query cannot find an element that has no role; the test turns red, and what it reports is a real defect.
How the Role and Name Are Computed
The following module builds an instructive subset of the computation: the implicit roles of
common elements, the precedence of the explicit role attribute, and the order of name
computation.
// query.mjs — element selection by accessible role and name export function o(type, props = {}, ...children) { return { type, props, children: children.flat() }; } export function* walk(node) { if (node === null || typeof node !== 'object') return; yield node; for (const child of node.children ?? []) yield* walk(child); } function text(node) { if (typeof node === 'string') return node; return (node.children ?? []).map(text).join(' ').replace(/\s+/g, ' ').trim(); } const IMPLICIT_ROLE = { button: () => 'button', a: (props) => (props.href === undefined ? null : 'link'), table: () => 'table', tr: () => 'row', td: () => 'cell', th: (props) => (props.scope === 'row' ? 'rowheader' : 'columnheader'), ul: () => 'list', li: () => 'listitem', nav: () => 'navigation', main: () => 'main', h1: () => 'heading', h2: () => 'heading', h3: () => 'heading', form: (props) => (props['aria-label'] === undefined ? null : 'form'), input: (props) => ({ search: 'searchbox', text: 'textbox', number: 'spinbutton', checkbox: 'checkbox', submit: 'button', })[props.type ?? 'text'] ?? null, }; export function role(node) { if (node.props?.role) return node.props.role; const rule = IMPLICIT_ROLE[node.type]; return rule ? rule(node.props ?? {}) : null; } export function accessibleName(tree, node) { const props = node.props ?? {}; if (props['aria-labelledby']) { const target = [...walk(tree)].find((d) => d.props?.id === props['aria-labelledby']); if (target) return text(target); } if (props['aria-label']) return props['aria-label']; if (props.id) { const label = [...walk(tree)].find((d) => d.type === 'label' && d.props?.for === props.id); if (label) return text(label); } if (['button', 'a', 'h1', 'h2', 'h3', 'td', 'th'].includes(node.type)) return text(node); if (props.title) return props.title; if (props.value !== undefined && node.type === 'input' && props.type === 'submit') return props.value; return ''; } export function byRole(tree, wanted, { name } = {}) { return [...walk(tree)].filter( (d) => role(d) === wanted && (name === undefined || accessibleName(tree, d) === name), ); } export function byClass(tree, className) { return [...walk(tree)].filter((d) => (d.props?.class ?? '').split(' ').includes(className)); }
Three details match the real computation. An explicit role attribute overrides the
implicit role. Name computation is a priority sequence, and the first one found wins: a
reference to another element, an explicitly given label string, a native label element, the
element’s own content, and title last. Some elements get a role conditioned on an
attribute — a link is a link only when it carries href, a section element counts as a
landmark only once it has a name.
The real computation is broader than this: the exclusion of hidden elements, the recursion of name computation, and the full role mapping are all defined in the standard. The subset here is enough to show what the query depends on.
Querying the Same Document by Two Criteria
The document under test is the measurement entry form. There are three versions: the initial markup, a version where only the class names changed, and a version where the save button turned into a container without a role.
// entry-form.mjs — three versions of the measurement entry form import { o } from './query.mjs'; // Version 1: initial markup export const version1 = () => o('form', { 'aria-label': 'Measurement entry' }, o('h2', { class: 'form__title' }, 'New measurement'), o('label', { for: 'station', class: 'form__label' }, 'Station'), o('input', { id: 'station', type: 'text', class: 'form__input' }), o('label', { for: 'value', class: 'form__label' }, 'Temperature'), o('input', { id: 'value', type: 'number', class: 'form__input' }), o('button', { type: 'submit', class: 'button button--primary' }, 'Save')); // Version 2: only class names and wrappers changed export const version2 = () => o('form', { 'aria-label': 'Measurement entry' }, o('h2', { class: 'stack__title' }, 'New measurement'), o('div', { class: 'field' }, o('label', { for: 'station', class: 'field__name' }, 'Station'), o('input', { id: 'station', type: 'text', class: 'field__value' })), o('div', { class: 'field' }, o('label', { for: 'value', class: 'field__name' }, 'Temperature'), o('input', { id: 'value', type: 'number', class: 'field__value' })), o('button', { type: 'submit', class: 'action action--primary' }, 'Save')); // Version 3: the save button turned into a container without a role export const version3 = () => o('form', { 'aria-label': 'Measurement entry' }, o('h2', { class: 'stack__title' }, 'New measurement'), o('div', { class: 'field' }, o('label', { for: 'station', class: 'field__name' }, 'Station'), o('input', { id: 'station', type: 'text', class: 'field__value' })), o('div', { class: 'field' }, o('label', { for: 'value', class: 'field__name' }, 'Temperature'), o('input', { id: 'value', type: 'number', class: 'field__value' })), o('div', { class: 'action action--primary', onclick: 'submit()' }, 'Save'));
// query.test.mjs import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { byRole, byClass, accessibleName } from './query.mjs'; import { version1, version2, version3 } from './entry-form.mjs'; describe('version 1', () => { test('role query finds the save button', () => { const tree = version1(); assert.equal(byRole(tree, 'button', { name: 'Save' }).length, 1); }); test('class query finds the same button', () => { assert.equal(byClass(version1(), 'button').length, 1); }); test('the accessible name of a labeled input is the label text', () => { const tree = version1(); const [input] = byRole(tree, 'spinbutton'); assert.equal(accessibleName(tree, input), 'Temperature'); }); }); describe('version 2 — class names changed', () => { test('role query still finds it', () => { const tree = version2(); assert.equal(byRole(tree, 'button', { name: 'Save' }).length, 1); }); test('class query no longer finds it', () => { assert.equal(byClass(version2(), 'button').length, 0); }); }); describe('version 3 — button turned into a roleless container', () => { test('class query keeps finding it', () => { assert.equal(byClass(version3(), 'action').length, 1); }); test('role query cannot find it', () => { const tree = version3(); assert.equal(byRole(tree, 'button', { name: 'Save' }).length, 0); }); });
node --test query.test.mjs
▶ version 1 ✔ role query finds the save button (0.628959ms) ✔ class query finds the same button (0.075542ms) ✔ the accessible name of a labeled input is the label text (0.072583ms) ✔ version 1 (1.13875ms) ▶ version 2 — class names changed ✔ role query still finds it (0.083208ms) ✔ class query no longer finds it (0.048042ms) ✔ version 2 — class names changed (0.192292ms) ▶ version 3 — button turned into a roleless container ✔ class query keeps finding it (0.084083ms) ✔ role query cannot find it (0.067042ms) ✔ version 3 — button turned into a roleless container (0.223083ms) ℹ tests 7 ℹ suites 3 ℹ pass 7 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 34.047208
The behavior of the two criteria across the three versions can be summarized in a table.
| Change | Role-and-name query | Class selector |
|---|---|---|
| Class names and wrappers changed | finds it | does not find it |
| Button turned into a roleless container | does not find it | finds it |
The two rows give two different kinds of information. In the first row, the class selector produces a false positive: the test turns red even though behavior has not changed. In the second row, the role query cannot find it, because there is no longer a button to find — that container cannot be focused with the keyboard, cannot be activated with the space bar, and is not announced to a screen reader as a button. The class selector still “finding” it in this version is not good news; it shows that the test does not see a real regression.
The Priority of Criteria
In practice, an order can be established. At the top are role and accessible name: this is the primary criterion for every interactive element. Next comes label text; in form fields, it already gives the same information together with the role. Third, for content that is not interactive, comes visible text — such as the message announced by an alert region.
At the very bottom stands the test attribute: a data attribute added only for testing. Its cost is that it creates a contract the user never sees; the test stays green even if the element drops out of the accessibility tree entirely. There are two justified cases: a structural container that has no accessible name by nature, and the need to pick out one specific element among many that share the same role and the same name.
A query finding more than one element is also information. If the same page has two buttons named “Save”, the test’s ambiguity is the user’s ambiguity; the fix is not to narrow the query but to differentiate the names.
Summary
- The user recognizes an element by its role and its name; when the query’s criteria are these two, the test carries the user’s point of view.
- The implicit role derives from the element type and its attributes; an explicit
roleattribute overrides it. Name computation is a priority sequence, and the first one found wins. - When class names and wrappers changed, the role query kept finding the element and the class selector produced a false positive.
- When the button turned into a roleless container, the role query could not find it; this is a real accessibility regression showing up in the test.
- The order of criteria is role and name, label text, visible text, and the test attribute last; the test attribute is a last resort because it creates a contract the user does not see.
Next Step
A component test is limited to the component’s own tree. The measurement list actually coming from the server, the address bar updating, the form being submitted, and the list refreshing all lie outside that boundary. What the user experiences is exactly this chain: while the parts work correctly one by one, the flow can break somewhere. The next lesson covers tests that run the application from start to finish in a real browser, modeling the flow as a state machine, and why these tests are kept few in number.
To keep your progress and take notes, Log in
My notes
Log in to take notes.