accessibility-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAccessibility Testing
无障碍测试
Overview
概述
Accessibility testing ensures web applications are usable by people with disabilities, including those using screen readers, keyboard navigation, or other assistive technologies. It validates compliance with WCAG (Web Content Accessibility Guidelines) and identifies barriers to accessibility.
无障碍测试确保Web应用可供残障人士使用,包括使用屏幕阅读器、键盘导航或其他辅助技术的用户。它验证是否符合WCAG(Web内容无障碍指南),并识别无障碍访问障碍。
When to Use
适用场景
- Validating WCAG 2.1/2.2 compliance
- Testing keyboard navigation
- Verifying screen reader compatibility
- Testing color contrast ratios
- Validating ARIA attributes
- Testing form accessibility
- Ensuring focus management
- Testing with assistive technologies
- 验证WCAG 2.1/2.2合规性
- 测试键盘导航
- 验证屏幕阅读器兼容性
- 测试色彩对比度
- 验证ARIA属性
- 测试表单无障碍性
- 确保焦点管理
- 使用辅助技术测试
WCAG Levels
WCAG 等级
- Level A: Basic accessibility (must have)
- Level AA: Intermediate accessibility (should have, legal requirement in many jurisdictions)
- Level AAA: Advanced accessibility (nice to have)
- Level A:基础无障碍要求(必须满足)
- Level AA:中级无障碍要求(建议满足,在许多地区属于法律强制要求)
- Level AAA:高级无障碍要求(锦上添花)
Instructions
操作指南
1. axe-core with Playwright
1. 结合Playwright使用axe-core
typescript
// tests/accessibility/homepage.a11y.test.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Homepage Accessibility', () => {
test('should not have any automatically detectable WCAG A or AA violations', async ({
page,
}) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('navigation should be accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.include('nav')
.analyze();
expect(results.violations).toEqual([]);
});
test('form should be accessible', async ({ page }) => {
await page.goto('/contact');
const results = await new AxeBuilder({ page })
.include('form')
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
// Additional form checks
const inputs = await page.locator('input, select, textarea').all();
for (const input of inputs) {
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledBy = await input.getAttribute('aria-labelledby');
// Every input should have an associated label
if (id) {
const label = await page.locator(`label[for="${id}"]`).count();
expect(
label > 0 || ariaLabel || ariaLabelledBy,
`Input with id="${id}" has no associated label`
).toBeTruthy();
}
}
});
test('images should have alt text', async ({ page }) => {
await page.goto('/');
const images = await page.locator('img').all();
for (const img of images) {
const alt = await img.getAttribute('alt');
const role = await img.getAttribute('role');
const ariaLabel = await img.getAttribute('aria-label');
// Decorative images should have empty alt or role="presentation"
// Content images must have descriptive alt text
expect(
alt !== null || role === 'presentation' || ariaLabel,
'Image missing alt text'
).toBeTruthy();
}
});
test('color contrast should meet AA standards', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['cat.color'])
.analyze();
expect(results.violations).toEqual([]);
});
});typescript
// tests/accessibility/homepage.a11y.test.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Homepage Accessibility', () => {
test('should not have any automatically detectable WCAG A or AA violations', async ({
page,
}) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('navigation should be accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.include('nav')
.analyze();
expect(results.violations).toEqual([]);
});
test('form should be accessible', async ({ page }) => {
await page.goto('/contact');
const results = await new AxeBuilder({ page })
.include('form')
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
// Additional form checks
const inputs = await page.locator('input, select, textarea').all();
for (const input of inputs) {
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledBy = await input.getAttribute('aria-labelledby');
// Every input should have an associated label
if (id) {
const label = await page.locator(`label[for="${id}"]`).count();
expect(
label > 0 || ariaLabel || ariaLabelledBy,
`Input with id="${id}" has no associated label`
).toBeTruthy();
}
}
});
test('images should have alt text', async ({ page }) => {
await page.goto('/');
const images = await page.locator('img').all();
for (const img of images) {
const alt = await img.getAttribute('alt');
const role = await img.getAttribute('role');
const ariaLabel = await img.getAttribute('aria-label');
// Decorative images should have empty alt or role="presentation"
// Content images must have descriptive alt text
expect(
alt !== null || role === 'presentation' || ariaLabel,
'Image missing alt text'
).toBeTruthy();
}
});
test('color contrast should meet AA standards', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['cat.color'])
.analyze();
expect(results.violations).toEqual([]);
});
});2. Keyboard Navigation Testing
2. 键盘导航测试
typescript
// tests/accessibility/keyboard-navigation.test.ts
import { test, expect } from '@playwright/test';
test.describe('Keyboard Navigation', () => {
test('should navigate through focusable elements with Tab', async ({
page,
}) => {
await page.goto('/');
// Get all focusable elements
const focusableSelectors =
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = await page.locator(focusableSelectors).all();
// Tab through all elements
for (let i = 0; i < focusableElements.length; i++) {
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(() => {
return {
tagName: document.activeElement?.tagName,
id: document.activeElement?.id,
className: document.activeElement?.className,
};
});
expect(focusedElement.tagName).toBeTruthy();
}
});
test('should skip navigation with skip link', async ({ page }) => {
await page.goto('/');
// Tab to skip link (usually first focusable element)
await page.keyboard.press('Tab');
const skipLink = await page.locator('.skip-link');
await expect(skipLink).toBeFocused();
// Activate skip link
await page.keyboard.press('Enter');
// Focus should be on main content
const focusedElement = await page.evaluate(() => {
return document.activeElement?.id;
});
expect(focusedElement).toBe('main-content');
});
test('modal should trap focus', async ({ page }) => {
await page.goto('/');
// Open modal
await page.click('[data-testid="open-modal"]');
await page.waitForSelector('[role="dialog"]');
const modal = page.locator('[role="dialog"]');
const focusableInModal = modal.locator(
'a[href], button, input, select, textarea'
);
const count = await focusableInModal.count();
// Tab through all elements in modal
for (let i = 0; i < count + 2; i++) {
await page.keyboard.press('Tab');
}
// Focus should still be within modal
const focusedElement = await page.evaluate(() => {
const modal = document.querySelector('[role="dialog"]');
return modal?.contains(document.activeElement);
});
expect(focusedElement).toBe(true);
});
test('dropdown menu should be keyboard accessible', async ({ page }) => {
await page.goto('/');
// Navigate to dropdown trigger
await page.keyboard.press('Tab');
const dropdown = page.locator('[data-testid="dropdown-menu"]');
await dropdown.focus();
// Open dropdown with Enter
await page.keyboard.press('Enter');
// Menu should be visible
const menu = page.locator('[role="menu"]');
await expect(menu).toBeVisible();
// Navigate menu items with arrow keys
await page.keyboard.press('ArrowDown');
const firstItem = menu.locator('[role="menuitem"]').first();
await expect(firstItem).toBeFocused();
await page.keyboard.press('ArrowDown');
const secondItem = menu.locator('[role="menuitem"]').nth(1);
await expect(secondItem).toBeFocused();
// Escape should close menu
await page.keyboard.press('Escape');
await expect(menu).not.toBeVisible();
await expect(dropdown).toBeFocused();
});
test('form can be completed using keyboard only', async ({ page }) => {
await page.goto('/contact');
// Tab to first field
await page.keyboard.press('Tab');
await page.keyboard.type('John Doe');
// Tab to email field
await page.keyboard.press('Tab');
await page.keyboard.type('john@example.com');
// Tab to message
await page.keyboard.press('Tab');
await page.keyboard.type('Test message');
// Tab to submit and activate
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
// Check form was submitted
await expect(page.locator('.success-message')).toBeVisible();
});
});typescript
// tests/accessibility/keyboard-navigation.test.ts
import { test, expect } from '@playwright/test';
test.describe('Keyboard Navigation', () => {
test('should navigate through focusable elements with Tab', async ({
page,
}) => {
await page.goto('/');
// Get all focusable elements
const focusableSelectors =
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = await page.locator(focusableSelectors).all();
// Tab through all elements
for (let i = 0; i < focusableElements.length; i++) {
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(() => {
return {
tagName: document.activeElement?.tagName,
id: document.activeElement?.id,
className: document.activeElement?.className,
};
});
expect(focusedElement.tagName).toBeTruthy();
}
});
test('should skip navigation with skip link', async ({ page }) => {
await page.goto('/');
// Tab to skip link (usually first focusable element)
await page.keyboard.press('Tab');
const skipLink = await page.locator('.skip-link');
await expect(skipLink).toBeFocused();
// Activate skip link
await page.keyboard.press('Enter');
// Focus should be on main content
const focusedElement = await page.evaluate(() => {
return document.activeElement?.id;
});
expect(focusedElement).toBe('main-content');
});
test('modal should trap focus', async ({ page }) => {
await page.goto('/');
// Open modal
await page.click('[data-testid="open-modal"]');
await page.waitForSelector('[role="dialog"]');
const modal = page.locator('[role="dialog"]');
const focusableInModal = modal.locator(
'a[href], button, input, select, textarea'
);
const count = await focusableInModal.count();
// Tab through all elements in modal
for (let i = 0; i < count + 2; i++) {
await page.keyboard.press('Tab');
}
// Focus should still be within modal
const focusedElement = await page.evaluate(() => {
const modal = document.querySelector('[role="dialog"]');
return modal?.contains(document.activeElement);
});
expect(focusedElement).toBe(true);
});
test('dropdown menu should be keyboard accessible', async ({ page }) => {
await page.goto('/');
// Navigate to dropdown trigger
await page.keyboard.press('Tab');
const dropdown = page.locator('[data-testid="dropdown-menu"]');
await dropdown.focus();
// Open dropdown with Enter
await page.keyboard.press('Enter');
// Menu should be visible
const menu = page.locator('[role="menu"]');
await expect(menu).toBeVisible();
// Navigate menu items with arrow keys
await page.keyboard.press('ArrowDown');
const firstItem = menu.locator('[role="menuitem"]').first();
await expect(firstItem).toBeFocused();
await page.keyboard.press('ArrowDown');
const secondItem = menu.locator('[role="menuitem"]').nth(1);
await expect(secondItem).toBeFocused();
// Escape should close menu
await page.keyboard.press('Escape');
await expect(menu).not.toBeVisible();
await expect(dropdown).toBeFocused();
});
test('form can be completed using keyboard only', async ({ page }) => {
await page.goto('/contact');
// Tab to first field
await page.keyboard.press('Tab');
await page.keyboard.type('John Doe');
// Tab to email field
await page.keyboard.press('Tab');
await page.keyboard.type('john@example.com');
// Tab to message
await page.keyboard.press('Tab');
await page.keyboard.type('Test message');
// Tab to submit and activate
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
// Check form was submitted
await expect(page.locator('.success-message')).toBeVisible();
});
});3. ARIA Testing
3. ARIA测试
typescript
// tests/accessibility/aria.test.ts
import { test, expect } from '@playwright/test';
test.describe('ARIA Attributes', () => {
test('buttons should have accessible names', async ({ page }) => {
await page.goto('/');
const buttons = await page.locator('button').all();
for (const button of buttons) {
const text = await button.textContent();
const ariaLabel = await button.getAttribute('aria-label');
const ariaLabelledBy = await button.getAttribute('aria-labelledby');
expect(
text?.trim() || ariaLabel || ariaLabelledBy,
'Button has no accessible name'
).toBeTruthy();
}
});
test('icons should have aria-hidden or labels', async ({ page }) => {
await page.goto('/');
const icons = await page
.locator('[class*="icon"], svg[class*="icon"]')
.all();
for (const icon of icons) {
const ariaHidden = await icon.getAttribute('aria-hidden');
const ariaLabel = await icon.getAttribute('aria-label');
const title = await icon.locator('title').count();
// Icon should be hidden from screen readers OR have a label
expect(
ariaHidden === 'true' || ariaLabel || title > 0,
'Icon without aria-hidden or accessible name'
).toBeTruthy();
}
});
test('custom widgets should have correct roles', async ({ page }) => {
await page.goto('/components');
// Tab widget
const tablist = page.locator('[role="tablist"]');
await expect(tablist).toHaveCount(1);
const tabs = tablist.locator('[role="tab"]');
const tabpanels = page.locator('[role="tabpanel"]');
expect(await tabs.count()).toBeGreaterThan(0);
expect(await tabs.count()).toBe(await tabpanels.count());
// Check aria-selected
const selectedTab = tabs.locator('[aria-selected="true"]');
await expect(selectedTab).toHaveCount(1);
// Check tab associations
const firstTab = tabs.first();
const ariaControls = await firstTab.getAttribute('aria-controls');
const associatedPanel = page.locator(`[id="${ariaControls}"]`);
await expect(associatedPanel).toHaveCount(1);
});
test('live regions announce changes', async ({ page }) => {
await page.goto('/');
// Find live region
const liveRegion = page.locator('[role="status"], [aria-live]');
// Trigger update
await page.click('[data-testid="load-data"]');
// Wait for content
await liveRegion.waitFor({ state: 'visible' });
const ariaLive = await liveRegion.getAttribute('aria-live');
expect(['polite', 'assertive']).toContain(ariaLive);
});
});typescript
// tests/accessibility/aria.test.ts
import { test, expect } from '@playwright/test';
test.describe('ARIA Attributes', () => {
test('buttons should have accessible names', async ({ page }) => {
await page.goto('/');
const buttons = await page.locator('button').all();
for (const button of buttons) {
const text = await button.textContent();
const ariaLabel = await button.getAttribute('aria-label');
const ariaLabelledBy = await button.getAttribute('aria-labelledby');
expect(
text?.trim() || ariaLabel || ariaLabelledBy,
'Button has no accessible name'
).toBeTruthy();
}
});
test('icons should have aria-hidden or labels', async ({ page }) => {
await page.goto('/');
const icons = await page
.locator('[class*="icon"], svg[class*="icon"]')
.all();
for (const icon of icons) {
const ariaHidden = await icon.getAttribute('aria-hidden');
const ariaLabel = await icon.getAttribute('aria-label');
const title = await icon.locator('title').count();
// Icon should be hidden from screen readers OR have a label
expect(
ariaHidden === 'true' || ariaLabel || title > 0,
'Icon without aria-hidden or accessible name'
).toBeTruthy();
}
});
test('custom widgets should have correct roles', async ({ page }) => {
await page.goto('/components');
// Tab widget
const tablist = page.locator('[role="tablist"]');
await expect(tablist).toHaveCount(1);
const tabs = tablist.locator('[role="tab"]');
const tabpanels = page.locator('[role="tabpanel"]');
expect(await tabs.count()).toBeGreaterThan(0);
expect(await tabs.count()).toBe(await tabpanels.count());
// Check aria-selected
const selectedTab = tabs.locator('[aria-selected="true"]');
await expect(selectedTab).toHaveCount(1);
// Check tab associations
const firstTab = tabs.first();
const ariaControls = await firstTab.getAttribute('aria-controls');
const associatedPanel = page.locator(`[id="${ariaControls}"]`);
await expect(associatedPanel).toHaveCount(1);
});
test('live regions announce changes', async ({ page }) => {
await page.goto('/');
// Find live region
const liveRegion = page.locator('[role="status"], [aria-live]');
// Trigger update
await page.click('[data-testid="load-data"]');
// Wait for content
await liveRegion.waitFor({ state: 'visible' });
const ariaLive = await liveRegion.getAttribute('aria-live');
expect(['polite', 'assertive']).toContain(ariaLive);
});
});4. Jest with jest-axe
4. 结合Jest使用jest-axe
typescript
// tests/components/Button.a11y.test.tsx
import React from 'react';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from '../Button';
expect.extend(toHaveNoViolations);
describe('Button Accessibility', () => {
test('should not have accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('icon button should have aria-label', async () => {
const { container } = render(
<Button aria-label="Close">
<CloseIcon />
</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('disabled button should be accessible', async () => {
const { container } = render(<Button disabled>Disabled</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});typescript
// tests/components/Button.a11y.test.tsx
import React from 'react';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from '../Button';
expect.extend(toHaveNoViolations);
describe('Button Accessibility', () => {
test('should not have accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('icon button should have aria-label', async () => {
const { container } = render(
<Button aria-label="Close">
<CloseIcon />
</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('disabled button should be accessible', async () => {
const { container } = render(<Button disabled>Disabled</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});5. Cypress Accessibility Testing
5. Cypress无障碍测试
javascript
// cypress/e2e/accessibility.cy.js
describe('Accessibility Tests', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('has no detectable a11y violations on load', () => {
cy.checkA11y();
});
it('navigation is accessible', () => {
cy.checkA11y('nav');
});
it('focuses on first error when form submission fails', () => {
cy.get('form').within(() => {
cy.get('[type="submit"]').click();
});
cy.focused().should('have.attr', 'aria-invalid', 'true');
});
it('modal has correct focus management', () => {
cy.get('[data-cy="open-modal"]').click();
// Focus should be in modal
cy.get('[role="dialog"]').should('exist');
cy.focused().parents('[role="dialog"]').should('exist');
// Close modal with Escape
cy.get('body').type('{esc}');
cy.get('[role="dialog"]').should('not.exist');
// Focus returns to trigger
cy.get('[data-cy="open-modal"]').should('have.focus');
});
});javascript
// cypress/e2e/accessibility.cy.js
describe('Accessibility Tests', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('has no detectable a11y violations on load', () => {
cy.checkA11y();
});
it('navigation is accessible', () => {
cy.checkA11y('nav');
});
it('focuses on first error when form submission fails', () => {
cy.get('form').within(() => {
cy.get('[type="submit"]').click();
});
cy.focused().should('have.attr', 'aria-invalid', 'true');
});
it('modal has correct focus management', () => {
cy.get('[data-cy="open-modal"]').click();
// Focus should be in modal
cy.get('[role="dialog"]').should('exist');
cy.focused().parents('[role="dialog"]').should('exist');
// Close modal with Escape
cy.get('body').type('{esc}');
cy.get('[role="dialog"]').should('not.exist');
// Focus returns to trigger
cy.get('[data-cy="open-modal"]').should('have.focus');
});
});6. Python with Selenium and axe
6. 结合Selenium和axe的Python测试
python
undefinedpython
undefinedtests/test_accessibility.py
tests/test_accessibility.py
import pytest
from selenium import webdriver
from axe_selenium_python import Axe
class TestAccessibility:
@pytest.fixture
def driver(self):
"""Setup Chrome driver."""
options = webdriver.ChromeOptions()
options.add_argument('--headless')
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
def test_homepage_accessibility(self, driver):
"""Test homepage for WCAG violations."""
driver.get('http://localhost:3000')
axe = Axe(driver)
axe.inject()
# Run axe accessibility tests
results = axe.run()
# Assert no violations
assert len(results['violations']) == 0, \
axe.report(results['violations'])
def test_form_accessibility(self, driver):
"""Test form accessibility."""
driver.get('http://localhost:3000/contact')
axe = Axe(driver)
axe.inject()
# Run with specific tags
results = axe.run(options={
'runOnly': {
'type': 'tag',
'values': ['wcag2a', 'wcag2aa', 'wcag21aa']
}
})
violations = results['violations']
assert len(violations) == 0, \
f"Found {len(violations)} accessibility violations"
def test_keyboard_navigation(self, driver):
"""Test keyboard navigation."""
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
driver.get('http://localhost:3000')
body = driver.find_element(By.TAG_NAME, 'body')
# Tab through focusable elements
for _ in range(10):
body.send_keys(Keys.TAB)
active = driver.switch_to.active_element
tag_name = active.tag_name
# Focused element should be interactive
assert tag_name in ['a', 'button', 'input', 'select', 'textarea'], \
f"Unexpected focused element: {tag_name}"undefinedimport pytest
from selenium import webdriver
from axe_selenium_python import Axe
class TestAccessibility:
@pytest.fixture
def driver(self):
"""Setup Chrome driver."""
options = webdriver.ChromeOptions()
options.add_argument('--headless')
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
def test_homepage_accessibility(self, driver):
"""Test homepage for WCAG violations."""
driver.get('http://localhost:3000')
axe = Axe(driver)
axe.inject()
# Run axe accessibility tests
results = axe.run()
# Assert no violations
assert len(results['violations']) == 0, \
axe.report(results['violations'])
def test_form_accessibility(self, driver):
"""Test form accessibility."""
driver.get('http://localhost:3000/contact')
axe = Axe(driver)
axe.inject()
# Run with specific tags
results = axe.run(options={
'runOnly': {
'type': 'tag',
'values': ['wcag2a', 'wcag2aa', 'wcag21aa']
}
})
violations = results['violations']
assert len(violations) == 0, \
f"Found {len(violations)} accessibility violations"
def test_keyboard_navigation(self, driver):
"""Test keyboard navigation."""
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
driver.get('http://localhost:3000')
body = driver.find_element(By.TAG_NAME, 'body')
# Tab through focusable elements
for _ in range(10):
body.send_keys(Keys.TAB)
active = driver.switch_to.active_element
tag_name = active.tag_name
# Focused element should be interactive
assert tag_name in ['a', 'button', 'input', 'select', 'textarea'], \
f"Unexpected focused element: {tag_name}"undefinedManual Testing Checklist
手动检查清单
Keyboard Navigation
键盘导航
- All interactive elements accessible via Tab
- Shift+Tab navigates backwards
- Enter/Space activates buttons/links
- Arrow keys work in custom widgets
- Escape closes modals/dropdowns
- Skip links present and functional
- Focus visible on all elements
- No keyboard traps
- 所有交互元素均可通过Tab键访问
- Shift+Tab可反向导航
- Enter/Space可激活按钮/链接
- 自定义组件支持方向键操作
- Escape可关闭模态框/下拉菜单
- 存在跳转链接且功能正常
- 所有元素的焦点可见
- 无键盘陷阱
Screen Readers
屏幕阅读器
- Page has descriptive title
- Headings form logical hierarchy
- Images have alt text
- Form inputs have labels
- Error messages announced
- Dynamic content changes announced
- Links have descriptive text
- Custom widgets have ARIA roles
- 页面有描述性标题
- 标题形成逻辑层级
- 图片包含替代文本
- 表单输入项有标签
- 错误消息可被朗读
- 动态内容变化可被朗读
- 链接有描述性文本
- 自定义组件有ARIA角色
Visual
视觉体验
- Color contrast meets AA (4.5:1 normal, 3:1 large text)
- Information not conveyed by color alone
- Text can be resized 200% without breaking
- Focus indicators visible
- Content readable in dark/light modes
- 色彩对比度符合AA级标准(普通文本4.5:1,大文本3:1)
- 信息不单独通过颜色传达
- 文本可放大200%且布局不混乱
- 焦点指示器可见
- 内容在深色/浅色模式下均可读
Best Practices
最佳实践
✅ DO
✅ 推荐做法
- Test with real assistive technologies
- Include keyboard-only users
- Test color contrast
- Use semantic HTML
- Provide text alternatives
- Test with screen readers
- Run automated tests in CI
- Follow WCAG 2.1 AA standards
- 使用真实辅助技术测试
- 覆盖仅使用键盘的用户
- 测试色彩对比度
- 使用语义化HTML
- 提供文本替代方案
- 使用屏幕阅读器测试
- 在CI流程中运行自动化测试
- 遵循WCAG 2.1 AA标准
❌ DON'T
❌ 避免做法
- Rely only on automated tests (they catch ~30-40% of issues)
- Use color alone to convey information
- Skip keyboard navigation testing
- Forget focus management in dynamic content
- Use div/span for interactive elements
- Hide focusable content with display:none
- Ignore ARIA best practices
- Skip manual testing
- 仅依赖自动化测试(仅能检测约30-40%的问题)
- 单独使用颜色传达信息
- 跳过键盘导航测试
- 忽略动态内容的焦点管理
- 使用div/span实现交互元素
- 用display:none隐藏可聚焦内容
- 忽略ARIA最佳实践
- 跳过手动测试
Tools
工具
- Automated Testing: axe-core, Pa11y, Lighthouse, WAVE
- Browser Extensions: axe DevTools, WAVE, Accessibility Insights
- Screen Readers: NVDA, JAWS, VoiceOver, TalkBack
- Color Contrast: WebAIM Contrast Checker, Stark
- Frameworks: jest-axe, cypress-axe, axe-playwright
- 自动化测试:axe-core、Pa11y、Lighthouse、WAVE
- 浏览器扩展:axe DevTools、WAVE、Accessibility Insights
- 屏幕阅读器:NVDA、JAWS、VoiceOver、TalkBack
- 色彩对比度:WebAIM Contrast Checker、Stark
- 测试框架:jest-axe、cypress-axe、axe-playwright
Standards
标准
- WCAG 2.1: Web Content Accessibility Guidelines
- WCAG 2.2: Latest version (2023)
- Section 508: US federal accessibility standard
- EN 301 549: European accessibility standard
- ADA: Americans with Disabilities Act
- WCAG 2.1:Web内容无障碍指南
- WCAG 2.2:最新版本(2023年发布)
- Section 508:美国联邦无障碍标准
- EN 301 549:欧洲无障碍标准
- ADA:美国残疾人法案
Examples
相关示例
See also: e2e-testing-automation, visual-regression-testing, accessibility-compliance for comprehensive accessibility implementation.
更多无障碍实现内容,请参考:e2e-testing-automation、visual-regression-testing、accessibility-compliance。