level 15 / keyboard-and-focus
Keyboard & Focus Testing
Test keyboard navigation, focus order, focus trapping in modals, and skip links with Playwright.
Why Keyboard Testing Matters
Keyboard navigation is required by WCAG 2.1.1 (Level A): all functionality must be operable with a keyboard. Users who rely on keyboard include:
- People with motor disabilities using keyboard or switch devices
- Power users who prefer keyboard over mouse
- Screen reader users (screen readers are keyboard-driven)
Playwright Keyboard API
// Tab through the page
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab'); // reverse direction
// Arrow keys for menus, sliders, radio groups
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowRight');
// Activate focused element
await page.keyboard.press('Enter');
await page.keyboard.press('Space');
// Close dialogs
await page.keyboard.press('Escape');
// Type into focused element
await page.keyboard.type('search query');
Testing Tab Order
test('form fields have logical tab order', async ({ page }) => {
await page.goto('/checkout');
// Start from first focusable element
await page.keyboard.press('Tab');
await expect(page.getByLabel('First name')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Last name')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Email')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Card number')).toBeFocused();
});
Testing Focus Visibility
test('focused button has visible outline', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const focusedEl = page.locator(':focus');
await expect(focusedEl).toBeVisible();
// Check outline is not 'none' via CSS
const outline = await focusedEl.evaluate(el =>
getComputedStyle(el).outlineStyle
);
expect(outline).not.toBe('none');
});
Testing Modal Focus Trap
WCAG 2.1 requires that when a dialog opens, focus moves into it and is trapped there until closed (no Tab out of the dialog).
test('modal traps focus correctly', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open dialog' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Focus should be inside dialog
const focusedEl = page.locator(':focus');
await expect(dialog).toContainText(await focusedEl.textContent() ?? '');
// Tab through all focusable elements — should wrap inside dialog
const focusableCount = await dialog.locator(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
).count();
for (let i = 0; i < focusableCount + 1; i++) {
await page.keyboard.press('Tab');
// Focus should never leave the dialog
const focused = page.locator(':focus');
await expect(dialog).toContainText(await focused.textContent() ?? '');
}
// Escape closes dialog
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
});
Testing Skip Links
Skip links let keyboard users jump past navigation directly to main content.
test('skip link is available and functional', async ({ page }) => {
await page.goto('/');
// Skip link is typically hidden until focused
await page.keyboard.press('Tab');
const skipLink = page.getByRole('link', { name: /skip to (main )?content/i });
await expect(skipLink).toBeVisible();
// Activating skip link moves focus to main content
await page.keyboard.press('Enter');
const main = page.locator('main, [role="main"]');
await expect(main).toBeFocused();
});
Testing Interactive Widgets
// Dropdown/combobox keyboard interaction
test('dropdown opens with keyboard and closes with Escape', async ({ page }) => {
await page.goto('/');
await page.getByRole('combobox', { name: 'Country' }).focus();
await page.keyboard.press('Space'); // or Enter, depending on implementation
await expect(page.getByRole('listbox')).toBeVisible();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter'); // select option
await expect(page.getByRole('listbox')).not.toBeVisible();
});
// Radio group keyboard interaction
test('radio group navigates with arrow keys', async ({ page }) => {
await page.goto('/survey');
const firstOption = page.getByRole('radio').first();
await firstOption.focus();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('radio').nth(1)).toBeFocused();
});
Keyboard Testing Checklist
✅ Tab moves forward through all interactive elements
✅ Shift+Tab moves backward
✅ Enter/Space activate buttons and links
✅ Escape closes modals and dropdowns
✅ Arrow keys work in menus, radio groups, sliders
✅ Focus is never trapped outside a dialog
✅ Focus is visible on all focused elements
✅ Skip links present and functional
✅ No keyboard traps (can Tab out of any element)