$npx -y skills add vibeeval/vibecosystem --skill accessibility-testingaxe-core integration, WCAG 2.2 AA checklist, keyboard navigation testing, screen reader testing, and ARIA pattern validation.
| 1 | # Accessibility Testing |
| 2 | |
| 3 | ## axe-core Setup |
| 4 | |
| 5 | ### jest-axe (unit / component tests) |
| 6 | ```typescript |
| 7 | import { axe, toHaveNoViolations } from 'jest-axe' |
| 8 | import { render } from '@testing-library/react' |
| 9 | expect.extend(toHaveNoViolations) |
| 10 | |
| 11 | test('LoginForm has no a11y violations', async () => { |
| 12 | const { container } = render(<LoginForm />) |
| 13 | const results = await axe(container) |
| 14 | expect(results).toHaveNoViolations() |
| 15 | }) |
| 16 | ``` |
| 17 | |
| 18 | ### playwright-axe (e2e) |
| 19 | ```typescript |
| 20 | import { test, expect } from '@playwright/test' |
| 21 | import AxeBuilder from '@axe-core/playwright' |
| 22 | |
| 23 | test('homepage passes axe audit', async ({ page }) => { |
| 24 | await page.goto('/') |
| 25 | const results = await new AxeBuilder({ page }) |
| 26 | .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) |
| 27 | .analyze() |
| 28 | expect(results.violations).toEqual([]) |
| 29 | }) |
| 30 | ``` |
| 31 | |
| 32 | ### cypress-axe |
| 33 | ```javascript |
| 34 | // cypress/support/e2e.js |
| 35 | import 'cypress-axe' |
| 36 | |
| 37 | // in test |
| 38 | cy.visit('/') |
| 39 | cy.injectAxe() |
| 40 | cy.checkA11y(null, { |
| 41 | runOnly: { type: 'tag', values: ['wcag2aa'] }, |
| 42 | }) |
| 43 | ``` |
| 44 | |
| 45 | ## WCAG 2.2 AA Checklist — Top 20 Violations |
| 46 | |
| 47 | | # | Criterion | Check | |
| 48 | |---|-----------|-------| |
| 49 | | 1 | Images have alt text | `<img alt="description">` or `alt=""` for decorative | |
| 50 | | 2 | Form inputs have labels | `<label for>` or `aria-label` or `aria-labelledby` | |
| 51 | | 3 | Color contrast ≥ 4.5:1 | Normal text; 3:1 for large text (18pt or 14pt bold) | |
| 52 | | 4 | Heading hierarchy | h1 → h2 → h3, no skipping levels | |
| 53 | | 5 | Keyboard focusable | All interactive elements reachable via Tab | |
| 54 | | 6 | Focus visible | `:focus` outline never `outline: none` without replacement | |
| 55 | | 7 | No keyboard trap | Tab can always exit modals, dropdowns, widgets | |
| 56 | | 8 | Skip navigation link | First focusable element: "Skip to main content" | |
| 57 | | 9 | Page has `<title>` | Unique, descriptive per page | |
| 58 | | 10 | Language attribute | `<html lang="en">` | |
| 59 | | 11 | Error identification | Form errors are text, not color-only | |
| 60 | | 12 | Error suggestions | Tell users how to fix the error | |
| 61 | | 13 | Link purpose clear | No "click here" or "read more" without context | |
| 62 | | 14 | Button text | No icon-only buttons without `aria-label` | |
| 63 | | 15 | Table headers | `<th scope="col|row">` on data tables | |
| 64 | | 16 | No seizure content | No flashing > 3 times/sec | |
| 65 | | 17 | Status messages | `role="status"` or `aria-live` for dynamic updates | |
| 66 | | 18 | Reflow at 400% zoom | Single column, no horizontal scroll | |
| 67 | | 19 | Text spacing adjustable | No overflow when line-height/letter-spacing increased | |
| 68 | | 20 | Timeout warning | Warn before session expires, allow extension | |
| 69 | |
| 70 | ## Keyboard Navigation Test Patterns |
| 71 | |
| 72 | ### Tab order verification |
| 73 | ```typescript |
| 74 | test('modal tab order is correct', async ({ page }) => { |
| 75 | await page.click('[data-testid="open-modal"]') |
| 76 | |
| 77 | // First focus should be the modal's close button or heading |
| 78 | await expect(page.locator('[aria-label="Close dialog"]')).toBeFocused() |
| 79 | |
| 80 | // Tab through: close → input → submit |
| 81 | await page.keyboard.press('Tab') |
| 82 | await expect(page.locator('#email-input')).toBeFocused() |
| 83 | |
| 84 | await page.keyboard.press('Tab') |
| 85 | await expect(page.locator('[type="submit"]')).toBeFocused() |
| 86 | |
| 87 | // Wrap back to close button (focus trap) |
| 88 | await page.keyboard.press('Tab') |
| 89 | await expect(page.locator('[aria-label="Close dialog"]')).toBeFocused() |
| 90 | }) |
| 91 | ``` |
| 92 | |
| 93 | ### Focus trap in modals |
| 94 | ```typescript |
| 95 | // Escape should close |
| 96 | await page.keyboard.press('Escape') |
| 97 | await expect(page.locator('[role="dialog"]')).not.toBeVisible() |
| 98 | |
| 99 | // Focus returns to trigger element after close |
| 100 | await expect(page.locator('[data-testid="open-modal"]')).toBeFocused() |
| 101 | ``` |
| 102 | |
| 103 | ### Skip navigation link |
| 104 | ```typescript |
| 105 | test('skip link goes to main content', async ({ page }) => { |
| 106 | await page.goto('/') |
| 107 | await page.keyboard.press('Tab') // first tab = skip link |
| 108 | |
| 109 | const skipLink = page.locator('a:has-text("Skip to")') |
| 110 | await expect(skipLink).toBeFocused() |
| 111 | |
| 112 | await page.keyboard.press('Enter') |
| 113 | await expect(page.locator('main, #main-content')).toBeFocused() |
| 114 | }) |
| 115 | ``` |
| 116 | |
| 117 | ### Arrow key navigation (listbox/menu) |
| 118 | ```typescript |
| 119 | test('dropdown menu responds to arrow keys', async ({ page }) => { |
| 120 | await page.click('[aria-haspopup="listbox"]') |
| 121 | await page.keyboard.press('ArrowDown') // first option focused |
| 122 | await page.keyboard.press('ArrowDown') // second option |
| 123 | await page.keyboard.press('Enter') // select |
| 124 | |
| 125 | // Verify selection |
| 126 | await expect(page.locator('[aria-selected="true"]')).toContainText('Option 2') |
| 127 | }) |
| 128 | ``` |
| 129 | |
| 130 | ## ARIA Patterns |
| 131 | |
| 132 | ### Live regions (dynamic announcements) |
| 133 | ```html |
| 134 | <!-- For important real-time updates (errors, confirmations) --> |
| 135 | <div role="alert">Your payment failed. Please try again.</div> |
| 136 | |
| 137 | <!-- For polite updates (search results count) --> |
| 138 | <div aria-live="polite" aria-atomic="true"> |
| 139 | Showing 42 results for "laptop" |
| 140 | </div> |
| 141 | |
| 142 | <!-- Screen reader only text (visually hidden) --> |
| 143 | <style> |
| 144 | .sr-only { |
| 145 | position: absolute; width: 1px; height: 1px |