---
title: 'User-Centric Queries'
source: 'https://academia.sh/en/courses/frontend-quality/user-centric-queries'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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'));
```

```js
// 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);
  });
});
```

```sh
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 `role`
  attribute 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.
