Testing Grid Keyboard Navigation With Playwright
Permalink to "Testing Grid Keyboard Navigation With Playwright"A data grid’s keyboard contract — arrows move between cells, Home and End go to row ends, Ctrl+Home to the first cell, Enter edits, Escape cancels, Tab leaves — is the part of grid accessibility most likely to regress quietly. A refactor changes an event handler, a new cell type swallows arrow keys, a virtualised row loses its tabindex, and axe still passes because the markup is valid. Keyboard tests catch exactly these regressions, and Playwright makes them straightforward.
This recipe expresses the contract as data and asserts it with role-based locators. It belongs to accessibility testing recipes.
Spec reference
Permalink to "Spec reference"Playwright APIs used:
page.keyboard.press('ArrowRight'), including modifiers ('Control+Home','Shift+ArrowDown');expect(locator).toBeFocused();locator.getAttribute('tabindex')to assert roving tabindex (exactly one cell with0);- role-based locators:
getByRole('grid', { name }),getByRole('gridcell', { name }),getByRole('row'),.nth().
The contract comes from the ARIA grid pattern plus your product’s decisions: whether arrows wrap at row ends, what Tab does in edit mode, whether Page Down moves by a visible page. SC 2.1.1 Keyboard and SC 2.1.2 No Keyboard Trap are the criteria under test, along with SC 2.4.3 Focus Order.
When to write keyboard tests — and how many
Permalink to "When to write keyboard tests — and how many"Write them for every custom grid, treegrid, listbox and composite widget in the product. Cover the contract once per component, and add a few journey tests at the page level (tab into the grid from the filter, edit, tab out to pagination).
Do not write them for native tables that users read with screen reader commands. Those use browser and screen reader keys, not yours; test that the markup is right instead (axe) and that interactive elements inside cells are reachable with Tab.
The misapplication to name is tests that click() cells to set up each case. Clicking moves focus in ways keyboard users cannot, and can mask a broken tabindex. Use the keyboard for everything after the first focus.
Annotated code example
Permalink to "Annotated code example"import { test, expect } from '@playwright/test';
const cell = (grid, r, c) => grid.getByRole('row').nth(r) // row 0 = header row
.getByRole('gridcell').nth(c - 1);
const CONTRACT = [
{ from: [1, 1], key: 'ArrowRight', to: [1, 2] },
{ from: [1, 2], key: 'ArrowDown', to: [2, 2] },
{ from: [2, 2], key: 'Home', to: [2, 1] },
{ from: [2, 1], key: 'End', to: [2, 4] },
{ from: [2, 4], key: 'Control+Home', to: [1, 1] },
{ from: [1, 1], key: 'ArrowLeft', to: [1, 1] }, // no wrap
];
test.describe('invoice grid keyboard contract', () => {
test.beforeEach(async ({ page }) => { await page.goto('/invoices'); });
test('grid is a single tab stop', async ({ page }) => {
const grid = page.getByRole('grid', { name: 'Open invoices' });
await page.getByRole('searchbox', { name: 'Filter invoices' }).focus();
await page.keyboard.press('Tab'); // into the grid
await expect(cell(grid, 1, 1)).toBeFocused();
// SC 2.4.3: exactly one cell in the tab order
await expect(grid.locator('[tabindex="0"]')).toHaveCount(1);
await page.keyboard.press('Tab'); // SC 2.1.2: out again
await expect(page.getByRole('navigation', { name: 'Invoices pages' })
.getByRole('button').first()).toBeFocused();
});
for (const { from, key, to } of CONTRACT) {
test(`${key} from ${from} → ${to}`, async ({ page }) => {
const grid = page.getByRole('grid', { name: 'Open invoices' });
await cell(grid, ...from).focus(); // only setup focus is programmatic
await page.keyboard.press(key);
await expect(cell(grid, ...to)).toBeFocused();
await expect(cell(grid, ...to)).toHaveAttribute('tabindex', '0'); // roving
});
}
test('edit mode: Enter edits, Escape cancels, focus returns', async ({ page }) => {
const grid = page.getByRole('grid', { name: 'Open invoices' });
const amount = cell(grid, 1, 4);
await amount.focus();
const before = await amount.textContent();
await page.keyboard.press('Enter');
const editor = grid.getByRole('textbox', { name: /Amount/ });
await expect(editor).toBeFocused();
await page.keyboard.type('999');
await page.keyboard.press('Escape');
await expect(amount).toBeFocused(); // SC 2.4.3
await expect(amount).toHaveText(before); // cancelled
});
});
Asserting tabindex="0" on the newly focused cell catches a subtle bug that toBeFocused alone misses: focus moved, but the roving tabindex did not, so tabbing out and back lands on the old cell.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"These tests exercise the keyboard, not the screen reader. What they cannot tell you:
| Concern | Covered by this recipe | Needs another test |
|---|---|---|
| Focus moves to the right cell | ✓ | — |
| Single tab stop and exit | ✓ | — |
| Edit mode keys | ✓ | — |
| Screen reader switches to focus mode | — | Manual / smoke test |
| Headers announced on move | — | Screen reader smoke test |
| Focus indicator visible | — | Focus screenshot test |
Integration context
Permalink to "Integration context"The contract under test is the one implemented in implementing roving tabindex for custom data grids and the edit-mode rules in entering and exiting cell edit mode. Structural checks in the same states come from axe scans of grid states with Playwright.
Selection keys (Shift+Arrow, Ctrl+A) are a second contract table with assertions on aria-selected counts, following extending selection with Shift and arrow keys.
Gotchas
Permalink to "Gotchas"Virtualised rows. End or Ctrl+End may target an unrendered row; assert after the grid has scrolled (toBeFocused waits and retries by default).
macOS modifiers. Use ControlOrMeta+Home where your product maps Cmd on macOS, and run the suite on both platforms if behaviour differs.
Focus in iframes. Grids inside iframes need frameLocator; page.keyboard still sends keys to the focused frame.
Design system notes
Permalink to "Design system notes"Ship each composite component’s keyboard contract as exported data alongside the component. The component’s own tests run it; product tests can import and run the same contract against their composed instances, catching wrappers that swallow keys.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"How do I test keyboard navigation in a data grid with Playwright?
Focus the grid, press keys with page.keyboard, and assert the expected cell with toBeFocused. Also assert that the focused cell has tabindex=“0” so the roving tabindex moved with it.
Should keyboard tests click cells to set up?
Only for the initial focus, and even then prefer focusing programmatically. Clicking can move focus in ways keyboard users cannot, hiding tabindex bugs.
Can Playwright tests check screen reader announcements?
Not directly. They can check that status messages appear in live regions, but what a screen reader speaks needs a virtual or real screen reader smoke test.
Which browsers should keyboard tests run in?
At least Chromium and WebKit, and Firefox if your users rely on it. Key handling and focus behaviour occasionally differ, especially around Home, End and modifier keys on macOS.
How do I keep keyboard tests maintainable?
Express the contract as data — start cell, key, expected cell — and run it in a loop. New keys are one line each, and the table doubles as documentation.
Related
Permalink to "Related"- axe scans of grid states — the structural half
- Roving tabindex for grids — the behaviour under test
- Entering and exiting edit mode — the edit contract