Keyboard Navigation Testing with Playwright — testing web accessibility for WCAG 2.1 AA compliance.
Keyboard navigation is the accessibility feature most commonly broken by JavaScript-heavy applications. Focus management bugs — focus trapped in a modal, focus jumping to the wrong element after an action, or focus not returning after a dialog closes — are invisible to sighted mouse users and catastrophic for keyboard users.
This lesson covers systematic keyboard navigation testing using Playwright's keyboard API: Tab order verification, focus management in modals, keyboard shortcuts, and ARIA keyboard interaction patterns.
1import { test, expect } from '@playwright/test';
2
3test('courses page Tab order follows visual layout', async ({ page }) => {
4 await page.goto('/courses');
5
6 // Collect Tab order by repeatedly pressing Tab
7 const tabOrder: string[] = [];
8 const maxTabs = 30;
9
10 for (let i = 0; i < maxTabs; i++) {
11 await page.keyboard.press('Tab');
12 const focused = await page.evaluate(() => {
13 const el = document.activeElement;
14 return el ? (el.getAttribute('aria-label') || el.textContent?.trim() || el.tagName) : null;
15 });
16 if (focused) tabOrder.push(focused);
17 }
18
19 console.log('Tab order:', tabOrder);
20
21 // Key elements must appear in logical order
22 const searchIdx = tabOrder.findIndex(t => t.toLowerCase().includes('search'));
23 const filterIdx = tabOrder.findIndex(t => t.toLowerCase().includes('filter'));
24 const firstCourseIdx = tabOrder.findIndex(t => t.toLowerCase().includes('course'));
25
26 // Search before Filter before Course cards
27 expect(searchIdx).toBeLessThan(filterIdx);
28 expect(filterIdx).toBeLessThan(firstCourseIdx);
29});
30
31test('focus returns to trigger after modal closes', async ({ page }) => {
32 await page.goto('/courses');
33 const trigger = page.locator('[data-testid="filter-button"]');
34 await trigger.focus();
35 await trigger.click();
36
37 // Modal opens
38 await expect(page.locator('[role="dialog"]')).toBeVisible();
39
40 // Close modal with Escape
41 await page.keyboard.press('Escape');
42 await expect(page.locator('[role="dialog"]')).not.toBeVisible();
43
44 // Focus returns to the trigger that opened the modal
45 const focusedElement = await page.evaluate(() => document.activeElement?.getAttribute('data-testid'));
46 expect(focusedElement).toBe('filter-button');
47});1test('modal focus trap: Tab cycles through modal elements only', async ({ page }) => {
2 await page.goto('/courses');
3 await page.click('[data-testid="enroll-modal-trigger"]');
4 await expect(page.locator('[role="dialog"]')).toBeVisible();
5
6 // Get all focusable elements inside the modal
7 const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
8 const focusableCount = await page.locator(`[role="dialog"] ${focusableSelectors}`).count();
9
10 // Tab through all modal elements
11 for (let i = 0; i < focusableCount + 2; i++) {
12 await page.keyboard.press('Tab');
13 const isInModal = await page.evaluate(() => {
14 const modal = document.querySelector('[role="dialog"]');
15 return modal?.contains(document.activeElement) ?? false;
16 });
17 expect(isInModal).toBe(true); // Focus must never leave the modal
18 }
19
20 // Shift+Tab from first element should go to last (circular trap)
21 const firstEl = await page.locator(`[role="dialog"] ${focusableSelectors}`).first();
22 await firstEl.focus();
23 await page.keyboard.press('Shift+Tab');
24 const isInModal = await page.evaluate(() => {
25 const modal = document.querySelector('[role="dialog"]');
26 return modal?.contains(document.activeElement) ?? false;
27 });
28 expect(isInModal).toBe(true);
29});ARIA widgets have defined keyboard patterns. Test that your implementations match the ARIA Authoring Practices Guide.
1// ARIA APG Combobox pattern: Arrow keys navigate options
2test('search combobox follows ARIA APG keyboard pattern', async ({ page }) => {
3 await page.goto('/courses');
4 const searchInput = page.locator('[role="combobox"]');
5 await searchInput.click();
6 await searchInput.type('type');
7
8 // Wait for options to appear
9 await page.waitForSelector('[role="option"]');
10 const optionCount = await page.locator('[role="option"]').count();
11 expect(optionCount).toBeGreaterThan(0);
12
13 // Arrow Down selects first option
14 await page.keyboard.press('ArrowDown');
15 const firstSelected = await page.locator('[role="option"][aria-selected="true"]').count();
16 expect(firstSelected).toBe(1);
17
18 // Enter confirms selection
19 await page.keyboard.press('Enter');
20 await expect(page.locator('[role="option"]')).not.toBeVisible();
21 const inputValue = await searchInput.inputValue();
22 expect(inputValue.length).toBeGreaterThan(0);
23
24 // Escape clears and closes
25 await searchInput.type('more');
26 await page.keyboard.press('Escape');
27 await expect(page.locator('[role="option"]')).not.toBeVisible();
28});
29
30// ARIA APG Tab panel pattern
31test('tab panel responds to arrow key navigation', async ({ page }) => {
32 await page.goto('/courses/typescript-fundamentals');
33 const tabList = page.locator('[role="tablist"]');
34 await tabList.locator('[role="tab"]').first().focus();
35
36 // ArrowRight moves to next tab
37 await page.keyboard.press('ArrowRight');
38 const activatedTab = await page.evaluate(() =>
39 document.activeElement?.getAttribute('aria-selected')
40 );
41 expect(activatedTab).toBe('true');
42});1test('skip to main content link is first focusable element', async ({ page }) => {
2 await page.goto('/');
3 await page.keyboard.press('Tab');
4
5 const firstFocused = await page.evaluate(() => {
6 const el = document.activeElement as HTMLElement;
7 return {
8 text: el?.textContent?.trim(),
9 href: el?.getAttribute('href'),
10 tag: el?.tagName,
11 };
12 });
13
14 // First Tab should land on skip link
15 expect(firstFocused.text?.toLowerCase()).toMatch(/skip|jump|main content/i);
16 expect(firstFocused.href).toBe('#main-content');
17});
18
19test('skip link actually moves focus to main landmark', async ({ page }) => {
20 await page.goto('/courses');
21 await page.keyboard.press('Tab');
22 await page.keyboard.press('Enter'); // activate skip link
23
24 const focusedRole = await page.evaluate(() => {
25 const el = document.activeElement;
26 return el?.tagName + el?.id;
27 });
28 // Focus should be on the main element or #main-content
29 const mainContent = page.locator('#main-content, main');
30 await expect(mainContent).toBeFocused();
31});
32
33test('landmark regions are present and properly labeled', async ({ page }) => {
34 await page.goto('/');
35 const header = await page.locator('header, [role="banner"]').count();
36 const main = await page.locator('main, [role="main"]').count();
37 const nav = await page.locator('nav, [role="navigation"]').count();
38 const footer = await page.locator('footer, [role="contentinfo"]').count();
39
40 expect(header).toBeGreaterThan(0);
41 expect(main).toBe(1); // exactly one main landmark
42 expect(nav).toBeGreaterThan(0);
43 expect(footer).toBeGreaterThan(0);
44});WCAG 2.4.7 requires visible focus indicators. WCAG 2.4.11 (AAA) requires enhanced focus appearance.
1test('all interactive elements have visible focus outline', async ({ page }) => {
2 await page.goto('/courses');
3
4 // Get all focusable elements
5 const focusableEls = await page.evaluate(() => {
6 const selectors = 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
7 return Array.from(document.querySelectorAll(selectors))
8 .slice(0, 20)
9 .map(el => (el as HTMLElement).offsetWidth > 0) // only visible elements
10 .filter(Boolean).length;
11 });
12
13 let elementsWithInvisibleFocus = 0;
14
15 for (let i = 0; i < Math.min(focusableEls, 10); i++) {
16 await page.keyboard.press('Tab');
17
18 const hasVisibleFocus = await page.evaluate(() => {
19 const el = document.activeElement as HTMLElement;
20 if (!el || el === document.body) return true;
21 const style = window.getComputedStyle(el);
22 const outlineWidth = parseFloat(style.outlineWidth);
23 const boxShadow = style.boxShadow;
24 // Check for outline or box-shadow focus indicator
25 const hasOutline = outlineWidth > 0 && style.outlineStyle !== 'none';
26 const hasBoxShadow = boxShadow !== 'none';
27 const hasBorderChange = style.border !== 'none';
28 return hasOutline || hasBoxShadow || hasBorderChange;
29 });
30
31 if (!hasVisibleFocus) {
32 elementsWithInvisibleFocus++;
33 const elInfo = await page.evaluate(() => document.activeElement?.tagName + ' ' + document.activeElement?.className);
34 console.warn('Invisible focus on:', elInfo);
35 }
36 }
37
38 // Allow at most 1 element with invisible focus (may be intentional)
39 expect(elementsWithInvisibleFocus).toBeLessThanOrEqual(1);
40});A working implementation demonstrating core patterns and best practices.
1// tests/a11y/keyboard.spec.ts
2import { test, expect } from '@playwright/test';
3
4test.describe('Keyboard Navigation Tests', () => {
5 test('login form: complete keyboard workflow', async ({ page }) => {
6 await page.goto('/login');
7
8 // Step through form fields with Tab
9 await page.keyboard.press('Tab');
10 const emailFocused = await page.evaluate(
11 () => (document.activeElement as HTMLInputElement)?.name === 'email'
12 );
13 expect(emailFocused).toBe(true);
14 await page.keyboard.type('[email protected]');
15
16 await page.keyboard.press('Tab');
17 await page.keyboard.type('Password123!');
18
19 await page.keyboard.press('Tab');
20 const submitFocused = await page.evaluate(
21 () => (document.activeElement as HTMLButtonElement)?.type === 'submit'
22 );
23 expect(submitFocused).toBe(true);
24
25 await page.keyboard.press('Enter');
26 // Should navigate after submit
27 await page.waitForURL(/\/dashboard|\/courses/, { timeout: 5000 }).catch(() => {
28 // Login may fail in test env — verify form submission happened
29 });
30 });
31
32 test('header navigation: all links reachable by Tab', async ({ page }) => {
33 await page.goto('/');
34 const navLinks = await page.locator('header nav a').all();
35 expect(navLinks.length).toBeGreaterThan(0);
36
37 let reachableCount = 0;
38 for (let i = 0; i < 20; i++) {
39 await page.keyboard.press('Tab');
40 const isNavLink = await page.evaluate(() => {
41 const el = document.activeElement;
42 const nav = document.querySelector('header nav');
43 return nav?.contains(el) && el?.tagName === 'A';
44 });
45 if (isNavLink) reachableCount++;
46 if (reachableCount >= navLinks.length) break;
47 }
48 expect(reachableCount).toBe(navLinks.length);
49 });
50
51 test('skip link is first Tab stop', async ({ page }) => {
52 await page.goto('/');
53 await page.keyboard.press('Tab');
54 const isSkipLink = await page.evaluate(() =>
55 (document.activeElement as HTMLAnchorElement)?.href?.includes('#main') ||
56 document.activeElement?.textContent?.toLowerCase().includes('skip')
57 );
58 expect(isSkipLink).toBe(true);
59 });
60
61 test('modal traps focus', async ({ page }) => {
62 await page.goto('/courses');
63 const modalTrigger = page.locator('[data-testid="filter-button"]').first();
64 if (await modalTrigger.count() === 0) return test.skip();
65
66 await modalTrigger.click();
67 await expect(page.locator('[role="dialog"]')).toBeVisible();
68
69 // Tab 10 times — focus must stay in modal
70 for (let i = 0; i < 10; i++) {
71 await page.keyboard.press('Tab');
72 const inModal = await page.evaluate(() => {
73 const modal = document.querySelector('[role="dialog"]');
74 return modal?.contains(document.activeElement) ?? false;
75 });
76 expect(inModal).toBe(true);
77 }
78
79 // Escape closes modal
80 await page.keyboard.press('Escape');
81 await expect(page.locator('[role="dialog"]')).not.toBeVisible();
82 });
83});This keyboard navigation test suite covers the four critical areas: complete form submission by keyboard only, all navigation links reachable by Tab, skip link as the first Tab stop, and focus trap in modals. The modal focus trap test Tabs 10 times and verifies focus never leaves the dialog. The skip link test verifies it is the first element encountered — a common WCAG 2.4.1 requirement that is often implemented but broken. Run: npx playwright test tests/a11y/keyboard.spec.ts
Continue building comprehensive test coverage with the patterns established in this lesson.
Your progress is saved in this browser, on this device only.