WCAG 2.2 无障碍访问
深入了解:使用 mcp__documentation__fetch_docs,并将 technology 设为 wcag,获取全面的 WCAG 指南、成功标准和技术。
不要使用此技能的情况
- 自动化测试配置 - 使用
axe-core 技能集成 axe 测试工具
- 组件库无障碍访问 - 使用特定框架的技能(例如 React、Vue)了解无障碍组件模式
- 设计系统 - 使用 UI 库技能获取预构建的无障碍组件
- 仅限 ARIA 实现 - 此技能涵盖更广泛的 WCAG 合规性,而不只是 ARIA
官方参考资料
合规等级
| 等级 | 说明 | 法律要求 |
|---|
| A | 最低无障碍要求 | 通常不足以满足要求 |
| AA | 标准无障碍要求 | 大多数法规(ADA、EN 301 549) |
| AAA | 增强型无障碍要求 | 特定场景 |
WCAG 2.2 成功标准数量
| 等级 | 2.2 新增 | 总数 |
|---|
| A | 0 | 30 |
| AA | 6 | 24 |
| AAA | 3 | 31 |
POUR 原则
1. 可感知
| 指南 | 主要标准 | 等级 |
|---|
| 1.1 文本替代 | 所有非文本内容都有文本替代 | A |
| 1.2 基于时间的媒体 | 字幕、音频描述 | A-AAA |
| 1.3 适应性 | 内容结构、有意义的顺序 | A |
| 1.4 可区分 | 颜色对比度、文本缩放、间距 | A-AAA |
// Text alternatives
<img src="chart.png" alt="Q3 sales increased 25% compared to Q2" />
// Decorative images
<img src="divider.png" alt="" role="presentation" />
// Color contrast (4.5:1 for normal text, 3:1 for large text)
// Use tools: WebAIM Contrast Checker, axe DevTools
2. 可操作
| 指南 | 主要标准 | 等级 |
|---|
| 2.1 键盘 | 所有功能均可通过键盘使用 | A |
| 2.2 充足时间 | 可调整的时间限制 | A-AAA |
| 2.4 可导航 | 跳过链接、焦点顺序、焦点可见 | A-AAA |
| 2.5 输入方式 | 目标尺寸、动作替代方式 | A-AAA |
// Skip link
<a href="#main-content" className="skip-link">
Skip to main content
</a>
// Focus visible (2.4.7)
button:focus {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
// Target size minimum (2.5.8) - 24x24 CSS pixels
.button {
min-width: 44px;
min-height: 44px;
padding: 12px 16px;
}
3. 可理解
| 指南 | 主要标准 | 等级 |
|---|
| 3.1 可读 | 页面语言、不常见词语 | A-AAA |
| 3.2 可预测 | 一致的导航、标识 | A-AA |
| 3.3 输入辅助 | 错误识别、标签 | A-AAA |
// Language of page (3.1.1)
<html lang="en">
// Error identification (3.3.1)
<div role="alert" aria-live="assertive">
Email is required and must be valid
</div>
// Labels (3.3.2)
<label htmlFor="email">Email Address</label>
<input id="email" type="email" aria-describedby="email-hint" />
<span id="email-hint">We'll never share your email</span>
4. 稳健
| 指南 | 主要标准 | 等级 |
|---|
| 4.1 兼容性 | 有效的 HTML、名称/角色/值 | A |
// Name, Role, Value (4.1.2)
<button aria-pressed="true" aria-label="Favorite this item">
★
</button>
// Custom controls
<div
role="slider"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={50}
aria-label="Volume"
tabIndex={0}
/>
WCAG 2.2 新增内容
AA 级(必需)
| 标准 | 说明 |
|---|
| 2.4.11 焦点未被遮挡(最低) | 焦点元素至少部分可见 |
| 2.4.13 焦点外观 | 焦点指示器符合尺寸/对比度要求 |
| 2.5.7 拖动操作 | 提供单指针拖动替代方式 |
| 2.5.8 目标尺寸(最低) | 最小尺寸为 24x24 CSS 像素 |
| 3.2.6 一致的帮助 | 帮助信息位于一致的位置 |
| 3.3.7 冗余输入 | 不要要求重新输入信息 |
AAA 级
| 标准 | 说明 |
|---|
| 2.4.12 焦点未被遮挡(增强) | 焦点元素完全可见 |
| 3.3.8 无障碍身份验证(最低) | 不得要求认知功能测试 |
| 3.3.9 无障碍身份验证(增强) | 不得要求识别物体/内容 |
常见模式
模态对话框
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
// Trap focus inside modal
modalRef.current?.focus();
// Prevent body scroll
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
tabIndex={-1}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
下拉菜单
function Dropdown({ label, items }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, items.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
break;
case 'Escape':
setIsOpen(false);
break;
case 'Enter':
case ' ':
if (activeIndex >= 0) items[activeIndex].onClick();
break;
}
};
return (
<div onKeyDown={handleKeyDown}>
<button
aria-haspopup="menu"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
>
{label}
</button>
{isOpen && (
<ul role="menu">
{items.map((item, i) => (
<li
key={item.id}
role="menuitem"
tabIndex={activeIndex === i ? 0 : -1}
aria-current={activeIndex === i}
>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
带验证的表单
function Form() {
const [errors, setErrors] = useState<Record<string, string>>({});
return (
<form aria-describedby="form-errors">
{Object.keys(errors).length > 0 && (
<div id="form-errors" role="alert" aria-live="polite">
<h2>Please fix the following errors:</h2>
<ul>
{Object.entries(errors).map(([field, msg]) => (
<li key={field}>
<a href={`#${field}`}>{msg}</a>
</li>
))}
</ul>
</div>
)}
<div>
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
<span className="sr-only">(required)</span>
</label>
<input
id="email"
type="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">{errors.email}</span>
)}
</div>
</form>
);
}
测试
使用 axe-core 进行自动化测试
// Playwright + axe
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage has no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
// Vitest + axe
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
expect.extend(toHaveNoViolations);
test('Button is accessible', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
手动测试清单
## Keyboard Navigation
- [ ] Tab through all interactive elements
- [ ] Shift+Tab navigates backwards
- [ ] Enter/Space activates buttons and links
- [ ] Arrow keys work in menus, tabs, sliders
- [ ] Escape closes modals and dropdowns
- [ ] No keyboard traps
## Screen Reader
- [ ] All images have alt text
- [ ] Form fields have labels
- [ ] Headings are hierarchical (h1 > h2 > h3)
- [ ] Links are descriptive (not "click here")
- [ ] Dynamic content announced (aria-live)
## Visual
- [ ] Color contrast meets 4.5:1 (normal text)
- [ ] Color contrast meets 3:1 (large text, UI components)
- [ ] Focus indicators visible
- [ ] Content readable at 200% zoom
- [ ] No horizontal scrolling at 320px width
工具
| 工具 | 用途 |
|---|
| axe DevTools | 用于审计的浏览器扩展 |
| WAVE | 视觉无障碍评估 |
| Lighthouse | 性能和无障碍审计 |
| NVDA/VoiceOver | 屏幕阅读器测试 |
| Color Contrast Analyzer | 对比度检查 |
CSS 工具类
/* Visually hidden but accessible to screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Focus visible only for keyboard users */
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* High contrast mode support */
@media (forced-colors: active) {
.button {
border: 2px solid currentColor;
}
}
检查清单
设计阶段
开发
测试
反模式
| 反模式 | 错误原因 | 正确做法 |
|---|
在没有 role 的 <div> 上使用 aria-label | 没有角色的非语义元素上的 ARIA 会被忽略 | 优先使用语义化 HTML:使用 <button>,而不是 <div role="button"> |
使用 <div onclick=""> 作为按钮 | 默认无法通过键盘访问 | 使用 <button> 并添加适当的事件处理程序 |
| 仅使用颜色作为指示 | 对色盲用户无效 | 在颜色旁添加图标、文字或图案 |
用 placeholder 替代标签 | 输入时会消失,且对比度低 | 始终使用带有 for 属性的 <label> |
tabindex > 0 | 会扰乱自然的 Tab 顺序 | 使用 tabindex="0",或依赖 DOM 顺序 |
在信息性图片上使用 alt="" | 屏幕阅读器会跳过重要内容 | 提供描述性的替代文本 |
| 缺少焦点指示器 | 用户无法看到当前焦点所在位置 | 始终显示 :focus-visible 样式 |
| 自动播放媒体 | 会让屏幕阅读器用户感到困惑 | 需要用户操作才能开始播放媒体 |
快速故障排查
| 问题 | 诊断 | 解决方案 |
|---|
| 屏幕阅读器未播报按钮 | 缺少可访问名称 | 添加 aria-label,或在按钮内添加可见文字 |
| 模态框中键盘焦点被困住 | 焦点管理不当 | 根据首尾元素逻辑实现焦点陷阱 |
| 未播报表单错误 | 缺少 role="alert" 或 aria-live | 在错误容器上添加 aria-live="assertive" |
| 低对比度警告 | 文字未达到 4.5:1 的比例 | 使用对比度检查工具,调整颜色或增大字号 |
| Tab 顺序不合理 | DOM 顺序与视觉顺序不一致 | 重新排列 DOM,使其与视觉布局一致,并避免使用 tabindex > 0 |
| 自定义下拉菜单不可访问 | 缺少 ARIA 角色和键盘操作支持 | 使用 role="combobox"、aria-expanded 和方向键导航 |
| 动态内容未播报 | 内容变化时没有提示 | 使用 aria-live="polite" 或 role="status" |
| 图片按钮没有标签 | 只有图标,没有文字替代内容 | 添加带有描述性文字的 aria-label |
相关技能