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();
});

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)