Keyboard Navigation Testing
Why Keyboard Testing Matters
If your checkout flow is not completable via keyboard only, you are excluding users who cannot use a mouse -- people with motor disabilities, power users who prefer keyboard navigation, and anyone with a temporary injury. Keyboard accessibility is also a legal requirement under WCAG 2.1.1 (Keyboard) and 2.4.7 (Focus Visible).
Complete Flow Testing via Keyboard
// tests/accessibility/keyboard-navigation.spec.ts
import { test, expect } from '@playwright/test';
test('entire checkout flow is completable via keyboard only', async ({ page }) => {
await page.goto('/cart');
// Tab to "Proceed to Checkout" button
let attempts = 0;
while (attempts < 20) {
await page.keyboard.press('Tab');
const focusedText = await page.evaluate(() =>
document.activeElement?.textContent?.trim()
);
if (focusedText?.includes('Proceed to Checkout')) break;
attempts++;
}
await page.keyboard.press('Enter');
// Verify we reached the checkout page
await expect(page).toHaveURL(/\/checkout/);
// Fill shipping form via keyboard
await page.keyboard.press('Tab'); // Focus first field
await page.keyboard.type('John Doe');
await page.keyboard.press('Tab');
await page.keyboard.type('123 Main St');
await page.keyboard.press('Tab');
await page.keyboard.type('Springfield');
await page.keyboard.press('Tab');
// Select state from dropdown via keyboard
await page.keyboard.press('Space'); // Open dropdown
await page.keyboard.type('Il'); // Type to search
await page.keyboard.press('Enter'); // Select Illinois
await page.keyboard.press('Tab');
await page.keyboard.type('62701'); // ZIP code
// Tab to submit and press Enter
attempts = 0;
while (attempts < 10) {
await page.keyboard.press('Tab');
const focusedRole = await page.evaluate(() =>
document.activeElement?.getAttribute('type')
);
if (focusedRole === 'submit') break;
attempts++;
}
await page.keyboard.press('Enter');
// Verify order confirmation
await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
});
Focus Trap Testing
Modal dialogs must trap focus -- Tab should cycle through elements inside the modal without escaping to the page behind it. This prevents users from accidentally interacting with content they cannot see.
test('focus trap works in modal dialogs', async ({ page }) => {
await page.goto('/');
// Open a modal
await page.click('[data-testid="open-modal"]');
await expect(page.locator('[role="dialog"]')).toBeVisible();
// Collect all focusable elements in the modal
const modalFocusable = await page.evaluate(() => {
const modal = document.querySelector('[role="dialog"]');
if (!modal) return [];
const focusable = modal.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
return Array.from(focusable).map(el => ({
tag: el.tagName,
text: el.textContent?.trim().substring(0, 20),
}));
});
expect(modalFocusable.length).toBeGreaterThan(0);
// Tab through all elements -- focus should cycle within the modal
for (let i = 0; i < modalFocusable.length + 2; i++) {
await page.keyboard.press('Tab');
const isInModal = await page.evaluate(() => {
const modal = document.querySelector('[role="dialog"]');
return modal?.contains(document.activeElement);
});
expect(isInModal).toBe(true);
}
// Escape should close the modal
await page.keyboard.press('Escape');
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
// Focus should return to the trigger element
const focusedAfterClose = await page.evaluate(() =>
document.activeElement?.getAttribute('data-testid')
);
expect(focusedAfterClose).toBe('open-modal');
});
Tab Order Verification
test('tab order follows logical visual flow', async ({ page }) => {
await page.goto('/checkout');
const tabOrder: string[] = [];
// Press Tab through the page and record the order
for (let i = 0; i < 30; i++) {
await page.keyboard.press('Tab');
const label = await page.evaluate(() => {
const el = document.activeElement;
return el?.getAttribute('aria-label')
|| el?.getAttribute('name')
|| el?.textContent?.trim().substring(0, 30)
|| el?.tagName
|| 'unknown';
});
tabOrder.push(label);
}
// Verify the expected tab order for checkout
const expectedOrder = [
'Full Name',
'Email',
'Address Line 1',
'Address Line 2',
'City',
'State',
'ZIP Code',
'Continue to Payment',
];
// Each expected element should appear in the tab order in sequence
let lastIndex = -1;
for (const expected of expectedOrder) {
const index = tabOrder.findIndex(
(label, i) => i > lastIndex && label.includes(expected)
);
expect(index).toBeGreaterThan(lastIndex);
lastIndex = index;
}
});
Skip Links
Skip links allow keyboard users to bypass repetitive navigation and jump directly to main content:
test('skip link is available and functional', async ({ page }) => {
await page.goto('/');
// Press Tab once -- skip link should be the first focusable element
await page.keyboard.press('Tab');
const skipLink = page.locator(':focus');
const text = await skipLink.textContent();
expect(text?.toLowerCase()).toContain('skip to');
// Skip link should be visible when focused
await expect(skipLink).toBeVisible();
// Activate the skip link
await page.keyboard.press('Enter');
// Focus should move to the main content area
const focusedId = await page.evaluate(() => document.activeElement?.id);
expect(focusedId).toBe('main-content');
});
Keyboard Interaction Patterns
Different component types have expected keyboard behaviors defined by WAI-ARIA:
| Component | Tab | Enter/Space | Arrow Keys | Escape |
|---|---|---|---|---|
| Button | Focus | Activate | N/A | N/A |
| Link | Focus | Navigate | N/A | N/A |
| Checkbox | Focus | Toggle | N/A | N/A |
| Radio group | Focus group | Select | Move selection | N/A |
| Dropdown/Select | Focus | Open | Navigate options | Close |
| Tab panel | Focus active tab | N/A | Switch tabs | N/A |
| Modal dialog | Focus first element | N/A | N/A | Close modal |
| Menu | Focus first item | Activate | Navigate items | Close menu |
| Accordion | Focus header | Toggle section | N/A | N/A |
test('dropdown keyboard interactions follow WAI-ARIA patterns', async ({ page }) => {
await page.goto('/form');
// Tab to the dropdown
const dropdown = page.locator('[role="listbox"]');
await dropdown.focus();
// Space opens the dropdown
await page.keyboard.press('Space');
await expect(page.locator('[role="option"]').first()).toBeVisible();
// Arrow down navigates options
await page.keyboard.press('ArrowDown');
const focused = await page.evaluate(() =>
document.activeElement?.getAttribute('aria-selected')
);
expect(focused).toBe('true');
// Enter selects the option
await page.keyboard.press('Enter');
await expect(page.locator('[role="option"]').first()).not.toBeVisible();
// Escape closes without selecting
await page.keyboard.press('Space'); // Reopen
await page.keyboard.press('Escape');
await expect(page.locator('[role="option"]').first()).not.toBeVisible();
});
Keyboard navigation testing catches real user pain. If your most critical flows are not completable via keyboard, you are both excluding users and inviting legal action. Test every critical user journey with keyboard-only interaction.