name: 无障碍合规性 description: 实施WCAG 2.1/2.2无障碍标准,屏幕阅读器兼容性,键盘导航和无障碍测试。在构建包容性Web应用程序、确保法规合规性或改善残疾人用户体验时使用。
无障碍合规性
概览
按照WCAG指南实施全面的无障碍功能,确保您的应用程序可以被每个人使用,包括残疾人。
使用场景
- 构建面向公众的Web应用程序
- 确保WCAG 2.1/2.2 AA或AAA合规性
- 支持屏幕阅读器(NVDA、JAWS、VoiceOver)
- 实施仅键盘导航
- 满足ADA、第508条款或类似法规
- 提升SEO和整体用户体验
- 进行无障碍审计
关键原则(POUR)
- 可感知 - 信息必须以用户可以感知的方式呈现
- 可操作 - 界面组件必须可操作
- 可理解 - 信息和操作必须是可理解的
- 健壮性 - 内容必须足够健壮,能够被辅助技术解释
实施示例
1. 语义HTML与ARIA
<!-- 不好:非语义标记 -->
<div class="button" onclick="submit()">提交</div>
<!-- 好:语义HTML -->
<button type="submit" aria-label="提交表单">提交</button>
<!-- 具有适当ARIA的自定义组件 -->
<div
role="button"
tabindex="0"
aria-pressed="false"
onclick="toggle()"
onkeydown="handleKeyPress(event)"
>
切换功能
</div>
<!-- 具有适当标签和错误处理的表单 -->
<form>
<label for="email">电子邮件地址</label>
<input
id="email"
type="email"
name="email"
aria-required="true"
aria-invalid="false"
aria-describedby="email-error"
/>
<span id="email-error" role="alert" aria-live="polite"></span>
</form>
2. 带有无障碍性的React组件
import React, { useRef, useEffect, useState } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const AccessibleModal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 保存之前的焦点
previousFocusRef.current = document.activeElement as HTMLElement;
// 焦点模态
modalRef.current?.focus();
// 在模态内捕获焦点
const trapFocus = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', trapFocus);
return () => {
document.removeEventListener('keydown', trapFocus);
// 恢复之前的焦点
previousFocusRef.current?.focus();
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
tabIndex={-1}
className="modal-overlay"
onClick={onClose}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="关闭模态"
className="close-button"
>
×
</button>
<div className="modal-body">
{children}
</div>
</div>
</div>
);
};
export default AccessibleModal;
3. 键盘导航处理器
// 键盘导航工具
export const KeyboardNavigation = {
// 处理列表中的箭头键导航
handleListNavigation: (event: KeyboardEvent, items: HTMLElement[]) => {
const currentIndex = items.findIndex(item =>
item === document.activeElement
);
let nextIndex: number;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
nextIndex = Math.min(currentIndex + 1, items.length - 1);
items[nextIndex]?.focus();
break;
case 'ArrowUp':
event.preventDefault();
nextIndex = Math.max(currentIndex - 1, 0);
items[nextIndex]?.focus();
break;
case 'Home':
event.preventDefault();
items[0]?.focus();
break;
case 'End':
event.preventDefault();
items[items.length - 1]?.focus();
break;
}
},
// 使元素可通过键盘访问
makeAccessible: (
element: HTMLElement,
onClick: () => void
): void => {
element.setAttribute('tabindex', '0');
element.setAttribute('role', 'button');
element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
});
}
};
4. 颜色对比度验证器
from typing import Tuple
import math
def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
"""将十六进制颜色转换为RGB。"""
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def calculate_luminance(rgb: Tuple[int, int, int]) -> float:
"""计算相对亮度。"""
def adjust(color: int) -> float:
c = color / 255.0
if c <= 0.03928:
return c / 12.92
return math.pow((c + 0.055) / 1.055, 2.4)
r, g, b = rgb
return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
def calculate_contrast_ratio(color1: str, color2: str) -> float:
"""计算WCAG对比度比率。"""
lum1 = calculate_luminance(hex_to_rgb(color1))
lum2 = calculate_luminance(hex_to_rgb(color2))
lighter = max(lum1, lum2)
darker = min(lum1, lum2)
return (lighter + 0.05) / (darker + 0.05)
def check_wcag_compliance(
foreground: str,
background: str,
level: str = 'AA',
large_text: bool = False
) -> dict:
"""检查颜色组合是否符合WCAG标准。"""
ratio = calculate_contrast_ratio(foreground, background)
# WCAG 2.1要求
requirements = {
'AA': {'normal': 4.5, 'large': 3.0},
'AAA': {'normal': 7.0, 'large': 4.5}
}
required_ratio = requirements[level]['large' if large_text else 'normal']
passes = ratio >= required_ratio
return {
'ratio': round(ratio, 2),
'required': required_ratio,
'passes': passes,
'level': level,
'grade': 'Pass' if passes else 'Fail'
}
# 使用方法
result = check_wcag_compliance('#000000', '#FFFFFF', 'AA', False)
print(f"对比度比率:{result['ratio']}:1") # 21:1
print(f"WCAG {result['level']}:{result['grade']}") # 通过
5. 屏幕阅读器公告
class ScreenReaderAnnouncer {
private liveRegion: HTMLElement;
constructor() {
this.liveRegion = this.createLiveRegion();
}
private createLiveRegion(): HTMLElement {
const region = document.createElement('div');
region.setAttribute('role', 'status');
region.setAttribute('aria-live', 'polite');
region.setAttribute('aria-atomic', 'true');
region.className = 'sr-only';
region.style.cssText = `
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
`;
document.body.appendChild(region);
return region;
}
announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
this.liveRegion.setAttribute('aria-live', priority);
// 清空然后设置消息以确保公告
this.liveRegion.textContent = '';
setTimeout(() => {
this.liveRegion.textContent = message;
}, 100);
}
cleanup(): void {
this.liveRegion.remove();
}
}
// 使用方法
const announcer = new ScreenReaderAnnouncer();
// 宣布表单验证错误
announcer.announce('电子邮件字段是必填项', 'assertive');
// 宣布成功的操作
announcer.announce('商品已添加到购物车', 'polite');
6. 焦点管理
class FocusManager {
private focusableSelectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll(this.focusableSelectors)
) as HTMLElement[];
}
trapFocus(container: HTMLElement): () => void {
const focusable = this.getFocusableElements(container);
const firstFocusable = focusable[0];
const lastFocusable = focusable[focusable.length - 1];
const handleTabKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
lastFocusable.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastFocusable) {
firstFocusable.focus();
e.preventDefault();
}
}
};
container.addEventListener('keydown', handleTabKey);
return () => container.removeEventListener('keydown', handleTabKey);
}
}
测试工具和技术
自动化测试
// Jest + Testing Library无障碍测试
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('无障碍性', () => {
it('不应该有无障碍违规', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('应该有适当的ARIA标签', () => {
render(<Button onClick={() => {}}>点击我</Button>);
const button = screen.getByRole('button', { name: /点击我/i });
expect(button).toBeInTheDocument();
});
it('应该是键盘可导航的', () => {
const { container } = render(<Navigation />);
const links = screen.getAllByRole('link');
links.forEach(link => {
expect(link).toHaveAttribute('href');
});
});
});
最佳实践
✅ 要做
- 使用语义HTML元素
- 为图像提供文本替代品
- 确保足够的颜色对比度(最低4.5:1)
- 支持键盘导航
- 实施焦点管理
- 用屏幕阅读器测试
- 正确使用ARIA属性
- 提供跳过链接
- 使表单无障碍,带有标签
- 支持文本放大至200%
❌ 不要做
- 仅依赖颜色来传达信息
- 移除焦点指示器
- 仅使用鼠标/触摸交互
- 无控制地自动播放媒体
- 创建键盘陷阱
- 使用正tabindex值
- 覆盖用户偏好设置
- 仅从视觉上隐藏应该从屏幕阅读器隐藏的内容
检查表
- [ ] 所有图像都有alt文本
- [ ] 颜色对比度符合WCAG AA标准
- [ ] 所有交互元素都可通过键盘访问
- [ ] 焦点指示器可见
- [ ] 表单输入有关联标签
- [ ] 错误消息向屏幕阅读器宣布
- [ ] 提供跳过链接
- [ ] 标题遵循层次顺序
- [ ] 正确使用ARIA属性
- [ ] 内容在200%缩放下可读
- [ ] 仅用键盘测试
- [ ] 用屏幕阅读器测试(NVDA、JAWS、VoiceOver)