name: accessibility description: 网页可访问性模式、WCAG合规性和包容性设计。当实现可访问的用户界面、键盘导航、屏幕阅读器支持、焦点管理、语义HTML或审核合规性时使用。触发词:可访问性、a11y、WCAG、ARIA、屏幕阅读器、键盘导航、焦点、标签顺序、tabindex、替代文本、颜色对比度、语义HTML、地标、角色、aria-label、aria-labelledby、aria-describedby、aria-live、aria-expanded、aria-selected、aria-hidden、焦点陷阱、轮转tabindex、跳过链接、辅助技术。
可访问性
概述
网页可访问性确保网站和应用程序对所有人,包括残障人士,都是可用的。这个技能涵盖WCAG 2.1合规性、语义HTML、ARIA模式、键盘导航、屏幕阅读器测试、视觉可访问性、焦点管理和自动化测试。
这个技能结合了工程实施和设计考虑 - 在构建可访问的用户界面、审核合规性或设计包容性用户体验时使用。
说明
1. WCAG 2.1 合规性检查清单
WCAG围绕四个原则(POUR)组织:
| 原则 | 描述 | 关键指南 |
|---|---|---|
| 可感知 | 信息必须对用户可呈现 | 文本替代、字幕、可适应内容 |
| 可操作 | 界面必须可操作 | 键盘可访问、足够时间、无发作 |
| 可理解 | 信息和操作必须可理解 | 可读、可预测、输入协助 |
| 健壮 | 内容必须对辅助技术健壮 | 与当前和未来工具兼容 |
一致性级别
- 级别 A: 最小可访问性(必须有)
- 级别 AA: 解决主要障碍(在许多司法管辖区是法律要求)
- 级别 AAA: 最高级别(对特定受众来说是锦上添花)
快速合规性检查清单
级别 A(必需):
- 所有图像都有替代文本(1.1.1)
- 视频有字幕(1.2.2)
- 颜色不是传达信息的唯一视觉手段(1.4.1)
- 所有功能都可以通过键盘访问(2.1.1)
- 用户可以暂停、停止或隐藏移动内容(2.2.2)
- 页面有标题(2.4.2)
- 焦点顺序保持意义(2.4.3)
- 链接目的从文本或上下文中清晰(2.4.4)
- 页面有标题和标签(2.4.6)
- 表单有标签或说明(3.3.2)
- 解析:无重复ID、适当嵌套(4.1.1)
- 所有UI组件的名称、角色、值可用(4.1.2)
级别 AA(标准):
- 所有实时音频的字幕(1.2.4)
- 视频的音频描述(1.2.5)
- 正常文本的对比度至少为4.5:1,大文本为3:1(1.4.3)
- 文本可以调整到200%而不失去功能(1.4.4)
- 避免使用文本图像(1.4.5)
- 多种方式查找页面(2.4.5)
- 标题和标签描述主题或目的(2.4.6)
- 焦点可见(2.4.7)
- 页面语言标识(3.1.1)
- 输入错误建议(3.3.3)
- 法律/金融交易的错误预防(3.3.4)
级别 AAA(增强):
- 视频的手语解释(1.2.6)
- 扩展音频描述(1.2.7)
- 对比度至少为7:1(1.4.6)
- 无背景音频或易于关闭(1.4.7)
- 使用部分标题(2.4.10)
- 上下文相关的帮助可用(3.3.5)
// WCAG 2.1 检查清单实现
interface WCAGChecklistItem {
id: string;
level: "A" | "AA" | "AAA";
principle: "perceivable" | "operable" | "understandable" | "robust";
description: string;
howToTest: string;
}
const wcagChecklist: WCAGChecklistItem[] = [
{
id: "1.1.1",
level: "A",
principle: "perceivable",
description:
"非文本内容:所有非文本内容都有文本替代",
howToTest: "检查所有图像都有有意义的替代文本",
},
{
id: "1.4.3",
level: "AA",
principle: "perceivable",
description:
"对比度(最小):文本对比度至少为4.5:1",
howToTest: "对所有文本元素使用对比度检查工具",
},
{
id: "2.1.1",
level: "A",
principle: "operable",
description: "键盘:所有功能都可以通过键盘操作",
howToTest: "不使用鼠标,通过Tab键遍历整个页面",
},
// ... 附加项目
];
2. 语义HTML
<!-- 坏:非语义结构 -->
<div class="header">
<div class="nav">
<div class="nav-item">主页</div>
<div class="nav-item">关于</div>
</div>
</div>
<div class="main-content">
<div class="article">
<div class="title">文章标题</div>
<div class="content">内容在这里...</div>
</div>
</div>
<div class="footer">页脚内容</div>
<!-- 好:语义结构 -->
<header>
<nav aria-label="主导航">
<ul>
<li><a href="/">主页</a></li>
<li><a href="/about">关于</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>文章标题</h1>
<p>内容在这里...</p>
</article>
</main>
<footer>页脚内容</footer>
语义元素参考
// 语义HTML元素映射
const semanticElements = {
// 分区
header: "介绍性内容,通常包含导航",
nav: "导航链接",
main: "文档的主要内容(每个页面只有一个)",
article: "可独立分发的自包含内容",
section: "带有标题的内容主题分组",
aside: "与主要内容相关的内容",
footer: "最近分区内容的页脚",
// 文本内容
h1_h6: "标题级别(保持层次结构,每个页面一个h1)",
p: "段落",
ul_ol: "无序/有序列表",
blockquote: "扩展引用",
figure: "带有可选标题的自包含内容",
figcaption: "图的标题",
// 交互式
button: "可点击按钮(不用于链接)",
a: "超链接到另一个页面或资源",
details: "带有摘要的披露小部件",
dialog: "模态或非模态对话框",
// 表单元素
form: "交互式表单",
label: "表单元素的标题(始终与输入一起使用)",
fieldset: "相关表单元素组",
legend: "fieldset的标题",
};
3. ARIA 属性
// ARIA 角色、状态和属性
// 地标角色
const landmarkRoles = [
"banner", // 页面头部(与<header>一起使用)
"navigation", // 导航(与<nav>一起使用)
"main", // 主要内容(与<main>一起使用)
"complementary", // 支持内容(与<aside>一起使用)
"contentinfo", // 页脚(与<footer>一起使用)
"search", // 搜索功能
"form", // 表单(与<form>一起使用)
"region", // 通用地标(需要aria-label)
];
// 小部件角色
const widgetRoles = [
"button",
"checkbox",
"dialog",
"menu",
"menuitem",
"progressbar",
"slider",
"tab",
"tablist",
"tabpanel",
"tooltip",
"tree",
"treeitem",
];
// 常见ARIA属性
interface AriaAttributes {
// 标签和描述
"aria-label": string; // 可访问名称
"aria-labelledby": string; // 标签元素的ID
"aria-describedby": string; // 描述元素的ID
// 状态
"aria-expanded": boolean; // 可扩展元素状态
"aria-selected": boolean; // 选择状态
"aria-checked": boolean | "mixed"; // 复选框/开关状态
"aria-pressed": boolean | "mixed"; // 切换按钮状态
"aria-disabled": boolean; // 禁用状态
"aria-hidden": boolean; // 从辅助技术隐藏
// 实时区域
"aria-live": "off" | "polite" | "assertive";
"aria-atomic": boolean;
"aria-relevant": string;
// 关系
"aria-controls": string; // 控制元素的ID
"aria-owns": string; // 拥有元素的ID
"aria-haspopup": boolean | "menu" | "dialog";
// 其他
"aria-current": "page" | "step" | "location" | "date" | "time" | boolean;
"aria-invalid": boolean | "grammar" | "spelling";
"aria-required": boolean;
}
ARIA 示例
// 可访问的模态对话框
function Modal({ isOpen, onClose, title, children }) {
const titleId = useId();
useEffect(() => {
if (isOpen) {
// 将焦点困在模态内
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
firstElement?.focus();
const handleTab = (e: KeyboardEvent) => {
if (e.key === "Tab") {
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleTab);
return () => document.removeEventListener("keydown", handleTab);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
ref={modalRef}
>
<h2 id={titleId}>{title}</h2>
{children}
<button onClick={onClose} aria-label="关闭对话框">
关闭
</button>
</div>
);
}
// 可访问的标签页
function Tabs({ tabs }) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: KeyboardEvent, index: number) => {
let newIndex = index;
switch (e.key) {
case "ArrowRight":
newIndex = (index + 1) % tabs.length;
break;
case "ArrowLeft":
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
setActiveIndex(newIndex);
document.getElementById(`tab-${newIndex}`)?.focus();
};
return (
<div>
<div role="tablist" aria-label="内容标签页">
{tabs.map((tab, index) => (
<button
key={index}
id={`tab-${index}`}
role="tab"
aria-selected={activeIndex === index}
aria-controls={`panel-${index}`}
tabIndex={activeIndex === index ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={index}
id={`panel-${index}`}
role="tabpanel"
aria-labelledby={`tab-${index}`}
hidden={activeIndex !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
// 实时区域用于动态更新
function Notification({ message, type }) {
return (
<div
role="alert"
aria-live={type === "error" ? "assertive" : "polite"}
aria-atomic="true"
>
{message}
</div>
);
}
4. 键盘导航
标准键盘模式
| 模式 | 键 | 行为 |
|---|---|---|
| Tab导航 | Tab, Shift+Tab | 向前/向后移动焦点通过交互元素 |
| 箭头导航 | 箭头键 | 在复合小部件内导航(菜单、标签页、列表) |
| 动作键 | Enter, Space | 激活按钮/链接(两者都可用Enter,Space用于按钮) |
| Escape | Esc | 关闭对话框、取消操作、退出模式 |
| Home/End | Home, End | 移动到组中的第一个/最后一个项目 |
| Page Up/Down | PgUp, PgDn | 滚动或按页面导航 |
| 类型前导 | 字母键 | 通过输入第一个字母查找项目 |
常见小部件模式
标签页:
- Tab: 进入标签列表
- 左/右箭头:在标签之间导航
- Home/End: 第一个/最后一个标签
- Tab: 退出到标签面板内容
菜单:
- 上/下箭头:导航菜单项
- 右箭头:打开子菜单
- 左箭头:关闭子菜单
- Home/End: 第一个/最后一个项目
- Escape: 关闭菜单
- 字母键:跳转到以字母开头的项目
对话框:
- Tab/Shift+Tab: 循环通过对话框元素(焦点陷阱)
- Escape: 关闭对话框
- 关闭时焦点返回到触发元素
手风琴:
- Tab: 移动到下一个手风琴标题
- 上/下箭头:导航手风琴标题(可选)
- Enter/Space: 切换手风琴面板
- Home/End: 第一个/最后一个标题(可选)
组合框:
- 下箭头:打开列表框
- 上/下箭头:导航选项
- Enter: 选择选项并关闭
- Escape: 关闭而不选择
- 类型前导:过滤/跳转到选项
// 键盘导航实用程序
// 焦点管理钩子
function useFocusManagement() {
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
const getFocusableElements = (container: HTMLElement) => {
return Array.from(container.querySelectorAll(focusableSelector));
};
const trapFocus = (container: HTMLElement) => {
const elements = getFocusableElements(container);
const first = elements[0] as HTMLElement;
const last = elements[elements.length - 1] as HTMLElement;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
};
return { getFocusableElements, trapFocus };
}
// 轮转tabindex用于分组元素
function useRovingTabindex<T extends HTMLElement>(
items: T[],
options: { orientation: "horizontal" | "vertical" | "both" } = {
orientation: "horizontal",
},
) {
const [focusedIndex, setFocusedIndex] = useState(0);
const handleKeyDown = (e: KeyboardEvent, index: number) => {
const { orientation } = options;
let newIndex = index;
const prevKeys = orientation === "vertical" ? ["ArrowUp"] : ["ArrowLeft"];
const nextKeys =
orientation === "vertical" ? ["ArrowDown"] : ["ArrowRight"];
if (orientation === "both") {
prevKeys.push("ArrowUp", "ArrowLeft");
nextKeys.push("ArrowDown", "ArrowRight");
}
if (prevKeys.includes(e.key)) {
newIndex = (index - 1 + items.length) % items.length;
} else if (nextKeys.includes(e.key)) {
newIndex = (index + 1) % items.length;
} else if (e.key === "Home") {
newIndex = 0;
} else if (e.key === "End") {
newIndex = items.length - 1;
} else {
return;
}
e.preventDefault();
setFocusedIndex(newIndex);
items[newIndex]?.focus();
};
return {
focusedIndex,
getTabIndex: (index: number) => (index === focusedIndex ? 0 : -1),
handleKeyDown,
};
}
// 跳过链接组件
function SkipLink({ targetId, children = "跳到主要内容" }) {
return (
<a
href={`#${targetId}`}
className="skip-link"
style={{
position: "absolute",
left: "-9999px",
top: "auto",
width: "1px",
height: "1px",
overflow: "hidden",
}}
onFocus={(e) => {
e.currentTarget.style.left = "0";
e.currentTarget.style.width = "auto";
e.currentTarget.style.height = "auto";
}}
onBlur={(e) => {
e.currentTarget.style.left = "-9999px";
e.currentTarget.style.width = "1px";
e.currentTarget.style.height = "1px";
}}
>
{children}
</a>
);
}
5. 屏幕阅读器测试
屏幕阅读器参考
| 阅读器 | 平台 | 成本 | 启用 | 关键快捷键 |
|---|---|---|---|---|
| NVDA | Windows | 免费 | 自动启动 | 开始: NVDA+Down, 停止: Ctrl, 下一个标题: H, 下一个链接: K, 下一个表单: F, 地标: D, 元素列表: NVDA+F7 |
| JAWS | Windows | 付费 | 自动启动 | 开始: Insert+Down, 停止: Ctrl, 下一个标题: H, 标题列表: Insert+F6 |
| VoiceOver | macOS/iOS | 内置 | Cmd+F5 | 下一个: VO+Right, 上一个: VO+Left, 转子: VO+U, 激活: VO+Space |
| TalkBack | Android | 内置 | 设置 | 下一个: 向右滑动, 上一个: 向左滑动, 激活: 双击, 滚动: 双指滑动 |
| Narrator | Windows | 内置 | Ctrl+Win+Enter | 扫描模式: Caps Lock+Space, 下一个标题: H, 下一个链接: K |
屏幕阅读器测试检查清单
导航:
- 页面标题在加载时宣布
- 标题创建逻辑大纲(h1 → h6层次结构保持)
- 地标区域正确标记(banner、navigation、main、complementary、contentinfo)
- 跳过链接允许跳过重复内容
- 焦点顺序遵循视觉阅读顺序
内容:
- 所有图像都有适当的替代文本(装饰性图像有alt=“”)
- 链接有描述性文本(避免“点击这里”)
- 按钮清晰指示其操作
- 动态内容更新被宣布(aria-live区域)
- 表格有适当的标题和说明
表单:
- 表单标签正确与输入关联
- 错误消息与表单字段关联(aria-describedby)
- 必填字段指示(aria-required)
- 无效字段指示(aria-invalid)
- 提交时宣布错误摘要
交互:
- 模态焦点被困在对话框内
- 模态宣布其角色和标签
- 键盘快捷键不与屏幕阅读器快捷键冲突
- 自定义小部件宣布状态变化(aria-expanded、aria-selected)
// 屏幕阅读器测试实用程序
const screenReaderTesting = {
// 常见屏幕阅读器
readers: {
nvda: {
platform: "Windows",
cost: "免费",
shortcuts: {
startReading: "NVDA + 下箭头",
stopReading: "Ctrl",
nextHeading: "H",
nextLink: "K",
nextFormField: "F",
nextLandmark: "D",
elementsList: "NVDA + F7",
},
},
jaws: {
platform: "Windows",
cost: "付费",
shortcuts: {
startReading: "Insert + 下箭头",
stopReading: "Ctrl",
nextHeading: "H",
nextLink: "Tab",
headingsList: "Insert + F6",
},
},
voiceover: {
platform: "macOS/iOS",
cost: "内置",
shortcuts: {
toggle: "Cmd + F5",
nextElement: "VO + 右箭头",
previousElement: "VO + 左箭头",
rotor: "VO + U",
activate: "VO + 空格",
},
},
talkback: {
platform: "Android",
cost: "内置",
gestures: {
nextElement: "向右滑动",
previousElement: "向左滑动",
activate: "双击",
scrollForward: "双指向上滑动",
},
},
},
};
// 屏幕阅读器宣布实用程序
function announce(
message: string,
priority: "polite" | "assertive" = "polite",
) {
const announcer = document.createElement("div");
announcer.setAttribute("aria-live", priority);
announcer.setAttribute("aria-atomic", "true");
announcer.setAttribute("class", "sr-only");
announcer.style.cssText = `
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
`;
document.body.appendChild(announcer);
// 延迟以确保屏幕阅读器检测到变化
setTimeout(() => {
announcer.textContent = message;
}, 100);
// 清理
setTimeout(() => {
document.body.removeChild(announcer);
}, 1000);
}
// 用法
announce("表单提交成功");
announce("错误:请填写所有必填字段", "assertive");
6. 颜色对比度和视觉可访问性
// 颜色对比度实用程序
function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function getContrastRatio(color1: string, color2: string): number {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
const l1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
const l2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 0, b: 0 };
}
function meetsWCAGContrast(
foreground: string,
background: string,
level: "AA" | "AAA" = "AA",
isLargeText: boolean = false,
): boolean {
const ratio = getContrastRatio(foreground, background);
const requirements = {
AA: { normal: 4.5, large: 3 },
AAA: { normal: 7, large: 4.5 },
};
const required = requirements[level][isLargeText ? "large" : "normal"];
return ratio >= required;
}
// 用法
const passes = meetsWCAGContrast("#333333", "#ffffff", "AA");
console.log(
`对比度比率: ${getContrastRatio("#333333", "#ffffff").toFixed(2)}:1`,
);
// 可访问调色板生成器
function generateAccessiblePalette(
baseColor: string,
background: string = "#ffffff",
) {
const shades = [];
const rgb = hexToRgb(baseColor);
for (let i = 0; i <= 100; i += 10) {
const factor = i / 100;
const shade = {
r: Math.round(rgb.r * factor),
g: Math.round(rgb.g * factor),
b: Math.round(rgb.b * factor),
};
const hex = `#${shade.r.toString(16).padStart(2, "0")}${shade.g.toString(16).padStart(2, "0")}${shade.b.toString(16).padStart(2, "0")}`;
const ratio = getContrastRatio(hex, background);
shades.push({
shade: i,
hex,
contrastRatio: ratio.toFixed(2),
passesAA: ratio >= 4.5,
passesAAA: ratio >= 7,
});
}
return shades;
}
// 焦点可见样式
const focusStyles = `
/* 移除默认轮廓 */
:focus {
outline: none;
}
/* 为键盘用户添加可见焦点 */
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
}
}
/* 减少运动支持 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
`;
7. 自动化A11y测试工具
// 自动化可访问性测试设置
// 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('应有可访问表单', () => {
render(<LoginForm />);
// 检查正确标签
expect(screen.getByLabelText('电子邮件')).toBeInTheDocument();
expect(screen.getByLabelText('密码')).toBeInTheDocument();
// 检查正确角色
expect(screen.getByRole('form')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
});
it('应正确管理焦点', () => {
render(<Modal isOpen={true} />);
// 第一个可聚焦元素应被聚焦
expect(document.activeElement).toBe(screen.getByRole('button', { name: '关闭' }));
});
});
// Playwright可访问性测试
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('可访问性', () => {
test('首页应无可访问性问题', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('应可通过键盘导航', async ({ page }) => {
await page.goto('/');
// Tab到跳过链接
await page.keyboard.press('Tab');
await expect(page.getByText('跳到主要内容')).toBeFocused();
// Tab到导航
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: '主页' })).toBeFocused();
});
});
// CI/CD集成可访问性
const accessibilityConfig = {
// Lighthouse CI配置
lighthouse: {
assertions: {
'categories:accessibility': ['error', { minScore: 0.9 }],
'color-contrast': 'error',
'document-title': 'error',
'html-has-lang': 'error',
'image-alt': 'error',
'label': 'error',
'link-name': 'error',
'meta-viewport': 'error',
},
},
// Pa11y CI配置
pa11y: {
standard: 'WCAG2AA',
runners: ['axe', 'htmlcs'],
ignore: [
'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail', // 如果需要,忽略特定规则
],
},
// Axe配置
axe: {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice'],
},
rules: {
'color-contrast': { enabled: true },
'valid-lang': { enabled: true },
},
},
};
最佳实践
-
从语义HTML开始:正确的HTML元素提供内置可访问性。使用button表示按钮,a表示链接,nav表示导航等。
-
ARIA第一规则:如果语义HTML可以做到,就不要使用ARIA。ARIA修复HTML无法表达的内容。
-
维护焦点管理:确保逻辑焦点顺序、可见焦点指示器以及模态中的正确焦点陷阱。
-
键盘优先:所有功能必须仅使用键盘即可工作。通过拔掉鼠标来测试。
-
使用真实工具测试:使用屏幕阅读器(NVDA、VoiceOver)、仅键盘以及自动化工具(axe、Lighthouse)。
-
提供文本替代:所有图像都需要替代文本(装饰性图像用alt=“”),视频需要字幕。
-
设计包容性:从一开始就考虑色盲、低视力、运动障碍、认知障碍。
-
渐进增强:核心功能应在没有JavaScript的情况下工作。为有能力的浏览器提供增强体验。
-
及早并经常测试:可访问性更容易在构建时加入,而不是后加。包括在每次代码审查中。
-
向用户学习:可能的话,在用户测试中包括残障人士。
示例
完整的可访问表单
function AccessibleForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const errorSummaryRef = useRef<HTMLDivElement>(null);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
// 验证
if (!formData.email) {
newErrors.email = "电子邮件是必填的";
}
if (!formData.password) {
newErrors.password = "密码是必填的";
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
// 为屏幕阅读器聚焦错误摘要
errorSummaryRef.current?.focus();
announce(
"表单有错误。请更正它们并重试。",
"assertive",
);
} else {
// 提交表单
announce("表单提交成功", "polite");
}
};
return (
<form onSubmit={handleSubmit} aria-labelledby="form-title" noValidate>
<h1 id="form-title">注册</h1>
{Object.keys(errors).length > 0 && (
<div
ref={errorSummaryRef}
role="alert"
aria-labelledby="error-summary-title"
tabIndex={-1}
className="error-summary"
>
<h2 id="error-summary-title">表单中存在错误</h2>
<ul>
{Object.entries(errors).map(([field, message]) => (
<li key={field}>
<a href={`#${field}`}>{message}</a>
</li>
))}
</ul>
</div>
)}
<div className="form-field">
<label htmlFor="email">
电子邮件地址
<span aria-hidden="true">*</span>
<span className="sr-only">(必填)</span>
</label>
<input
type="email"
id="email"
name="email"
autoComplete="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
<div className="form-field">
<label htmlFor="password">
密码
<span aria-hidden="true">*</span>
<span className="sr-only">(必填)</span>
</label>
<input
type="password"
id="password"
name="password"
autoComplete="new-password"
aria-required="true"
aria-invalid={!!errors.password}
aria-describedby="password-hint password-error"
/>
<span id="password-hint" className="hint">
必须至少8个字符
</span>
{errors.password && (
<span id="password-error" role="alert" className="error">
{errors.password}
</span>
)}
</div>
<button type="submit">创建账户</button>
</form>
);
}
快速参考
常见ARIA模式速查表
// 按钮
<button type="button">点击我</button>
// 切换按钮
<button aria-pressed="true">静音</button>
// 图标按钮
<button aria-label="关闭">×</button>
// 看起来像按钮的链接(不要 - 改用按钮)
// 如果必须:<a href="#" role="button" aria-pressed="false">
// 禁用状态
<button disabled>无法点击</button>
<button aria-disabled="true">视觉上禁用但可聚焦</button>
// 加载状态
<button aria-busy="true">保存中...</button>
// 可扩展部分
<button aria-expanded="false" aria-controls="panel-id">切换</button>
<div id="panel-id" hidden>内容</div>
// 自定义复选框
<div role="checkbox" aria-checked="false" tabindex="0">选项</div>
// 模态对话框
<div role="dialog" aria-modal="true" aria-labelledby="title-id">
<h2 id="title-id">对话框标题</h2>
</div>
// 警报(立即宣布)
<div role="alert">错误:表单提交失败</div>
// 状态(在当前话语后宣布)
<div role="status" aria-live="polite">5条新消息</div>
// 标签页
<div role="tablist">
<button role="tab" aria-selected="true" aria-controls="panel1">标签1</button>
<button role="tab" aria-selected="false" aria-controls="panel2">标签2</button>
</div>
<div id="panel1" role="tabpanel">面板1内容</div>
<div id="panel2" role="tabpanel" hidden>面板2内容</div>
// 带有描述和错误的表单字段
<label for="email">电子邮件</label>
<input
id="email"
type="email"
aria-describedby="email-hint email-error"
aria-invalid="true"
aria-required="true"
/>
<span id="email-hint">我们永远不会分享您的电子邮件</span>
<span id="email-error" role="alert">请输入有效的电子邮件</span>
// 视觉隐藏但屏幕阅读器可用
<span className="sr-only">仅屏幕阅读器文本</span>
// CSS: .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; }
// 从屏幕阅读器隐藏
<div aria-hidden="true">装饰性内容</div>
替代文本指南
// 信息性图像 - 描述信息
<img src="chart.png" alt="销售从第一季度到第二季度增加了25%" />
// 功能性图像 - 描述动作
<img src="print-icon.png" alt="打印此页面" />
// 装饰性图像 - 空alt
<img src="decorative-border.png" alt="" />
// 复杂图像 - 使用长描述
<img
src="complex-diagram.png"
alt="系统架构图"
aria-describedby="diagram-desc"
/>
<div id="diagram-desc">
详细描述:系统由...组成
</div>
// 文本图像 - 尽可能避免,否则复制文本
<img src="logo.png" alt="Acme公司" />
// 背景图像 - 使用空alt,其他地方提供文本替代
<div style="background-image: url(hero.jpg)" role="img" aria-label="团队庆祝">
焦点管理模式
// 焦点陷阱(模态、侧边栏)
// 1. 保存之前聚焦的元素
const previousFocus = document.activeElement;
// 2. 聚焦陷阱中的第一个元素
modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')?.focus();
// 3. 在Tab时,在陷阱内循环
// 参见上面的useFocusManagement钩子
// 4. 关闭时,恢复焦点
previousFocus?.focus();
// 轮转tabindex(工具栏、菜单)
// 组中只有一个项目有tabindex="0",其他有tabindex="-1"
// 箭头键移动焦点并更新tabindex值
<div role="toolbar">
<button tabindex="0">剪切</button>
<button tabindex="-1">复制</button>
<button tabindex="-1">粘贴</button>
</div>
// 跳过链接(第一个可聚焦元素)
<a href="#main-content" className="skip-link">跳到主要内容</a>
<main id="main-content" tabindex="-1">...</main>
// 删除后的焦点管理
// 聚焦下一个项目,如果是最后一个则前一个,如果空则容器
listItems.splice(index, 1);
if (listItems[index]) {
listItems[index].focus();
} else if (listItems[index - 1]) {
listItems[index - 1].focus();
} else {
containerElement.focus();
}
测试检查清单
自动化(在每个PR上运行):
- axe-core(通过jest-axe或@axe-core/playwright)
- Lighthouse可访问性分数
- ESLint插件:eslint-plugin-jsx-a11y
手动(发布前运行):
- 仅键盘导航(拔掉鼠标)
- 屏幕阅读器测试(NVDA/VoiceOver)
- 浏览器缩放至200%
- 颜色对比度检查器(WCAG AA最低)
- Tab遍历整个应用程序
- 检查焦点指示器可见
- 测试减少运动偏好
- 测试高对比度模式
浏览器开发工具:
- Chrome: Lighthouse、可访问性树视图
- Firefox: 可访问性检查器
- Safari: 可访问性审计