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/playwrightprovidesAxeBuilderwith.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-busyremoved, a dialog’sopenattribute — 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).
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 |
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.
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.
Related
Permalink to "Related"- Configuring axe-core rules — the shared options and ledger
- Testing grid keyboard navigation — the behaviour axe cannot check
- pa11y-ci for multi-state pages — the configuration-only alternative