Keyboard Navigation, Focus Management and ARIA Testing in Cypress

๐Ÿ“‹ Table of Contents โ–พ
  1. Keyboard Navigation and Focus Testing
  2. Common Mistakes

axe-core checks static properties โ€” missing labels, colour contrast ratios, ARIA attribute values. But accessibility is also about interaction: can a keyboard user reach every interactive element? Does focus move logically through the page? Does a modal trap focus inside itself? Does the Escape key close popups? These interaction patterns require Cypress tests that simulate keyboard-only navigation and verify focus behaviour โ€” the 60% of accessibility that automated scanners cannot cover.

Keyboard Navigation and Focus Testing

Cypress’s .type() and .trigger() commands can simulate keyboard interactions, and cy.focused() tells you which element currently has focus.

// โ”€โ”€ TAB ORDER: verify focus moves through elements logically โ”€โ”€

it('should tab through login form in correct order', () => {
  cy.visit('/');

  // First Tab โ†’ username field
  cy.get('body').tab();  // Requires cypress-plugin-tab or realPress
  cy.focused().should('have.attr', 'data-test', 'username');

  // Second Tab โ†’ password field
  cy.focused().tab();
  cy.focused().should('have.attr', 'data-test', 'password');

  // Third Tab โ†’ login button
  cy.focused().tab();
  cy.focused().should('have.attr', 'data-test', 'login-button');
});

// Alternative without tab plugin โ€” use :focus pseudo-class
it('should show focus styles on interactive elements', () => {
  cy.visit('/');
  cy.get('[data-test="username"]').focus();
  cy.focused().should('have.attr', 'data-test', 'username');

  // Verify focus style is visible (outline or ring)
  cy.focused().should('have.css', 'outline-style').and('not.eq', 'none');
});


// โ”€โ”€ MODAL FOCUS TRAP: focus must stay inside the modal โ”€โ”€

it('should trap focus inside the modal when open', () => {
  cy.visit('/shop');
  cy.get('[data-cy="open-modal"]').click();

  // Modal should be visible
  cy.get('[data-cy="modal"]').should('be.visible');

  // Focus should be inside the modal (first focusable element)
  cy.focused().should('be.descendantOf', '[data-cy="modal"]');

  // Tab through all modal elements โ€” focus should NOT leave the modal
  cy.get('[data-cy="modal"]').within(() => {
    // Tab to the last focusable element
    cy.get('button, input, [tabindex]').last().focus();
    // Tab again โ€” should cycle back to the first focusable element, not leave
    cy.focused().tab();
    cy.focused().should('be.descendantOf', '[data-cy="modal"]');
  });

  // Escape should close the modal
  cy.get('body').type('{esc}');
  cy.get('[data-cy="modal"]').should('not.exist');

  // Focus should return to the element that opened the modal
  cy.focused().should('have.attr', 'data-cy', 'open-modal');
});


// โ”€โ”€ SKIP LINK: keyboard users should be able to skip navigation โ”€โ”€

it('should have a working skip-to-content link', () => {
  cy.visit('/');

  // Skip link is typically the first focusable element, hidden until focused
  cy.get('body').tab();
  cy.focused()
    .should('contain.text', 'Skip to main content')
    .and('have.attr', 'href', '#main-content');

  // Activate skip link
  cy.focused().type('{enter}');

  // Focus should jump to the main content area
  cy.focused().should('have.attr', 'id', 'main-content');
});


// โ”€โ”€ ARIA LIVE REGIONS: verify dynamic announcements โ”€โ”€

it('should announce cart update to screen readers', () => {
  cy.visit('/shop');

  // Before adding item โ€” live region should be empty or have initial text
  cy.get('[aria-live="polite"]').should('exist');

  // Add item to cart
  cy.get('[data-cy="add-to-cart"]').first().click();

  // Live region should announce the update
  cy.get('[aria-live="polite"]')
    .should('contain.text', 'added to cart');
});


// โ”€โ”€ ARIA ATTRIBUTES: verify correct ARIA usage โ”€โ”€

it('should have correct ARIA attributes on accordion', () => {
  cy.visit('/faq');

  // Accordion trigger should have aria-expanded
  cy.get('[data-cy="accordion-trigger"]').first()
    .should('have.attr', 'aria-expanded', 'false');

  // Click to expand
  cy.get('[data-cy="accordion-trigger"]').first().click();
  cy.get('[data-cy="accordion-trigger"]').first()
    .should('have.attr', 'aria-expanded', 'true');

  // Panel should have matching aria-labelledby
  cy.get('[data-cy="accordion-panel"]').first()
    .should('have.attr', 'role', 'region')
    .and('have.attr', 'aria-labelledby');
});


// โ”€โ”€ Key accessibility interactions to test โ”€โ”€

const A11Y_INTERACTIONS = [
    { pattern: 'Tab order',      test: 'Tab through page; verify focus order matches visual order' },
    { pattern: 'Focus visible',  test: 'Focus each element; verify visible focus indicator (outline/ring)' },
    { pattern: 'Modal focus trap', test: 'Open modal; Tab cannot leave; Escape closes; focus returns' },
    { pattern: 'Skip link',      test: 'First Tab reveals skip link; Enter jumps to main content' },
    { pattern: 'Escape key',     test: 'Modals, dropdowns, tooltips close on Escape' },
    { pattern: 'Arrow keys',     test: 'Menus, tabs, radio groups navigate with arrow keys' },
    { pattern: 'Enter/Space',    test: 'Buttons activate on Enter and Space; links on Enter only' },
    { pattern: 'ARIA states',    test: 'aria-expanded, aria-selected, aria-checked update on interaction' },
    { pattern: 'Live regions',   test: 'Dynamic updates announced via aria-live="polite"' },
];
Note: Focus management after modal close is a frequently missed accessibility requirement. When a modal opens, focus should move inside the modal. When it closes, focus should return to the element that triggered the modal โ€” not to the top of the page. Without this “focus return” pattern, keyboard users lose their place in the page every time they interact with a modal. Test this explicitly: cy.focused().should('have.attr', 'data-cy', 'open-modal') after closing.
Tip: The cy.focused() command returns whichever element currently has keyboard focus. Use it to verify focus position after every Tab press, modal open/close, and skip link activation. It is the accessibility equivalent of cy.url() for navigation โ€” it tells you where the user “is” from a keyboard perspective at any point in the test.
Warning: Cypress’s .type('{tab}') does not actually move focus to the next element โ€” it types a Tab character into the focused element. For true Tab navigation, use the cypress-plugin-tab plugin: cy.focused().tab(). Alternatively, use cy.realPress('Tab') from cypress-real-events which sends native browser key events. Without these plugins, Tab navigation tests silently test the wrong thing.

Common Mistakes

Mistake 1 โ€” Using .type(‘{tab}’) instead of a proper Tab plugin

โŒ Wrong: cy.get('#input').type('{tab}') โ€” types a tab character, does not move focus to the next element.

โœ… Correct: cy.focused().tab() (with cypress-plugin-tab) or cy.realPress('Tab') (with cypress-real-events) โ€” simulates real Tab key navigation.

Mistake 2 โ€” Not testing focus return after modal close

โŒ Wrong: Testing that a modal opens and closes but not verifying where focus goes after close.

โœ… Correct: After modal close, asserting that focus returned to the trigger element: cy.focused().should('have.attr', 'data-cy', 'modal-trigger').

🧠 Test Yourself

A modal dialog opens. Which three accessibility behaviours must be verified in a Cypress test?