axe Scans of Grid States With Playwright

Permalink to "axe Scans of Grid States With Playwright"

A data grid’s accessibility changes with its state. The loaded grid may be perfect; the sorted grid may have two headers with aria-sort; the filtered-empty grid may lose its table structure entirely; the editing state may put an unlabelled input in a cell; the error state may render a message with no association. An axe scan of the page after load sees only the first. This recipe drives the grid through each state and scans each one, with a helper small enough to copy into any project.

It belongs to accessibility testing recipes, and uses the rule configuration from configuring axe-core rules and handling false positives.

Spec reference

Permalink to "Spec reference"
  • @axe-core/playwright provides AxeBuilder with .include(), .exclude(), .withTags(), .options() and .analyze().
  • Playwright’s test.step() groups actions and scans per state in the report; testInfo.attach() adds JSON results.
  • Settled signals: a status region’s text, a row count, aria-busy removed, a dialog’s open attribute — anything that proves the state has finished rendering.

Rules most often triggered by state changes in grids: aria-required-children (rows missing after filtering), empty-table-header, label and aria-input-field-name (cell editors), aria-allowed-attr (aria-selected or aria-sort in the wrong place), color-contrast (error and selected styles), duplicate-id-aria (re-rendered rows reusing ids).

Grid states and the rules they tend to trip Mock table listing grid states, the action that reaches each, and the axe rules each state most often violates. Grid states and the rules they tend to tripStateReached byRules to watchSortedclick header bu…aria-allowed-attrFiltered e…type no-match q…aria-required-children1EditingEnter on a celllabel, aria-input-field…2Row errorsave invalid va…color-contrast, aria-valid…Dialog openrow menu → Editaria-dialog-name1Empty grids that drop all rows can leaverole="grid" with no row children2Cell editors are the most common sourceof unlabelled inputs in grids
Every state below has shipped a violation that the loaded-state scan could not see.

When to use this recipe — and when a component test is enough

Permalink to "When to use this recipe — and when a component test is enough"

Use it for application-level grids — the composed grid with real toolbars, filters, dialogs and data — where state interactions are where bugs live. Run it on every pull request that touches the grid or its page.

For a design-system grid component, a unit-level scan per state with jest-axe or Vitest may be enough and faster; see writing jest-axe tests for data grid components. This recipe then covers the composition.

The misapplication to name is scanning the whole page in every state. Page chrome violations (a header link, a footer image) are reported again and again, drowning the grid’s findings. Scope each scan to the grid and its related controls.

Annotated code example

Permalink to "Annotated code example"
// a11y.helpers.js
import AxeBuilder from '@axe-core/playwright';
import { AXE_OPTIONS } from './axe.config.js';
import { applySuppressions } from './a11y-suppressions.js';

export async function scan(page, testInfo, state, { include = ['#invoice-grid'], also = [] } = {}) {
  const builder = new AxeBuilder({ page }).options(AXE_OPTIONS);
  [...include, ...also].forEach((sel) => builder.include(sel));
  const results = await builder.analyze();
  const violations = applySuppressions(results.violations);
  await testInfo.attach(`axe-${state}.json`, {
    body: JSON.stringify({ state, violations, incomplete: results.incomplete.length }, null, 2),
    contentType: 'application/json',
  });
  return violations;
}
// invoice-grid.a11y.spec.js
import { test, expect } from '@playwright/test';
import { scan } from './a11y.helpers.js';

test('invoice grid: every state has no axe violations', async ({ page }, testInfo) => {
  await page.goto('/invoices');
  const status = page.getByRole('status');
  const grid = page.getByRole('grid', { name: 'Open invoices' });

  await test.step('loaded', async () => {
    await expect(grid.getByRole('row')).toHaveCount(26);            // header + 25
    expect(await scan(page, testInfo, 'loaded')).toEqual([]);
  });

  await test.step('sorted', async () => {
    await page.getByRole('button', { name: 'Amount' }).click();
    await expect(status).toContainText('Sorted by Amount');
    expect(await scan(page, testInfo, 'sorted')).toEqual([]);
  });

  await test.step('filtered to empty', async () => {
    await page.getByRole('searchbox', { name: 'Filter invoices' }).fill('zzz-no-match');
    await expect(page.getByText('No invoices match')).toBeVisible();
    expect(await scan(page, testInfo, 'empty', { also: ['.empty-state'] })).toEqual([]);
  });

  await test.step('editing a cell', async () => {
    await page.getByRole('searchbox', { name: 'Filter invoices' }).fill('');
    await grid.getByRole('gridcell', { name: '1,280.00' }).press('Enter');
    await expect(grid.getByRole('textbox')).toBeFocused();
    expect(await scan(page, testInfo, 'editing')).toEqual([]);
  });

  await test.step('validation error', async () => {
    await grid.getByRole('textbox').fill('abc');
    await grid.getByRole('textbox').press('Enter');
    await expect(grid.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
    expect(await scan(page, testInfo, 'error')).toEqual([]);
  });

  await test.step('edit dialog open', async () => {
    await page.keyboard.press('Escape');
    await page.getByRole('button', { name: 'Actions for INV-1042' }).click();
    await page.getByRole('menuitem', { name: 'Edit' }).click();
    await expect(page.getByRole('dialog', { name: /Edit invoice/ })).toBeVisible();
    expect(await scan(page, testInfo, 'dialog', { include: ['dialog[open]'] })).toEqual([]);
  });
});

The settled signals do double duty. Waiting for “Sorted by Amount” in the status region proves both that the state has rendered and that the announcement exists — a cheap behavioural assertion alongside the structural scan.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"

axe scans check structure; this recipe’s actions can still exercise the keyboard where it is cheap. The editing step above uses press('Enter') on a cell rather than a click, which also verifies the edit-mode key.

Step Structural check (axe) Free behavioural check
Sorted aria-sort placement valid Status message present
Filtered empty Grid structure valid when empty Empty-state text visible
Editing Editor has a name Enter opens the editor; focus moves in
Error Error association valid aria-invalid set
Dialog Dialog named, structure valid Menu and dialog reachable by role
One state, one step Flow of a single state step in the recipe: perform the action, wait for a settled signal, run a scoped axe scan with suppressions, attach results, assert no violations. One state, one stepActclick, type orpressSettlewait for proofScanscoped, sharedoptionsAttachJSON per stateAssertno violations
The same five moves for every state — which is what makes adding a new state a two-minute job.

Integration context

Permalink to "Integration context"

axe cannot check keyboard models or announcements; pair this recipe with testing grid keyboard navigation with Playwright and, per release, with screen reader smoke tests. For projects that prefer configuration over code, pa11y-ci for multi-state data pages covers similar ground.

The pipeline wiring — running on pull requests, publishing reports — is in setting up axe-core in a GitHub Actions pipeline.

Page-after-load scan versus per-state scans Comparison of a single axe scan after page load against scoped scans in each grid state. Page-after-load scan versus per-state scans✗ One scan after loadSees the initial rows onlyMisses editors, errors and dialogsPage chrome findings mixed inPasses while the grid breaks on sort✓ Scoped scan per stateSix states, six scansEditors, errors, empty and dialogs coveredFindings labelled by stateSettled signals double as behaviour checks
A single scan tests the state users see least; per-state scans test the states they work in.

Gotchas

Permalink to "Gotchas"

Virtualised grids. A scan sees only rendered rows. That is fine for structure, but scroll to the bottom and scan once more if the last rows render differently (totals, load-more rows).

Animations. Scanning mid-transition can report contrast on semi-transparent elements. Disable animations in tests with reducedMotion: 'reduce' in the Playwright config.

Test data. Seed data that exercises edge cases — long names, negative numbers, empty cells — so the scans see the markup those cases produce.

Design system notes

Permalink to "Design system notes"

Ship the scan helper and the state list template with the design system’s testing utilities, so every product’s grid tests look the same and report per state. The design system’s own grid can publish its supported states so products know which ones to cover.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Why scan a data grid in more than one state?

Because much of a grid’s markup only exists after interaction — sort indicators, cell editors, validation errors, empty states and dialogs. A scan after page load cannot see any of them.

How do I know a state has finished rendering before scanning?

Wait for something that only exists in the settled state, such as the status message after sorting, the empty-state text, an open dialog or an aria-invalid attribute. Avoid fixed timeouts.

Should the scan cover the whole page?

Scope it to the grid and the controls related to the state, such as an open dialog. Whole-page scans repeat chrome findings in every state and hide the grid’s own issues.

Does passing these scans mean the grid is accessible?

No. It means the grid’s structure is valid in those states. Keyboard behaviour and announcements need their own tests and periodic manual checks.

Permalink to "Related"

← Back to Accessibility Testing Recipes