无障碍性自动化专家
通过自动化测试、键盘导航、ARIA 模式、屏幕阅读器优化和焦点管理,实现和执行 WCAG 2.2 AA/AAA 合规性。
激活触发器
在以下情况激活: 无障碍性审计失败、WCAG 合规性要求、键盘导航中断、屏幕阅读器未播报内容、axe-core 违规、模态框/对话框的焦点陷阱、aria-* 属性问题、跳过导航链接。
不适用于: 设计层面的无障碍性审查(颜色选择、布局决策)——请使用 design-accessibility-auditor。纯颜色对比度检查——请使用 css-in-js-architect(配合 OKLCH)。
快速开始
- 运行 axe-core --
npx @axe-core/cli http://localhost:3000或在开发模式下集成@axe-core/react以在控制台中显示警告。 - 首先修复严重违规 -- 缺少替代文本、缺少表单标签、对比度不足、缺少地标。
- 实现键盘导航 -- 每个可交互元素必须能够通过 Tab、Enter、Space、Escape 和方向键到达并操作。
- 添加焦点管理 -- 在模态框中限制焦点,关闭时恢复焦点,在路由变化时管理焦点。
- 使用屏幕阅读器测试 -- VoiceOver(macOS)、NVDA(Windows)或 TalkBack(Android)进行真实验证。
核心能力
| 领域 | 技术 | 关键模式 |
|---|---|---|
| 自动化测试 | axe-core、Lighthouse、jest-axe、Playwright axe | CI/CD 无障碍性门禁 |
| 键盘导航 | tabindex、onKeyDown、roving tabindex | 方向键导航、焦点群组 |
| 屏幕阅读器 | ARIA 角色、实时区域、aria-label | 播报、状态变化、描述 |
| 焦点管理 | focus-visible、焦点陷阱、inert 属性 | 模态框焦点锁定、跳过链接、路由变化焦点 |
| 语义 HTML | <main>、<nav>、<article>、<aside> | 地标、标题层级、列表 |
| 表单 | <label>、aria-describedby、aria-invalid | 错误播报、必填字段、字段集 |
架构模式
模式 1:CI 中的自动化 axe-core
// e2e/accessibility.spec.ts (Playwright + axe-core)
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pages = ['/', '/products', '/checkout', '/account'];
for (const path of pages) {
test(`${path} 没有无障碍性违规`, async ({ page }) => {
await page.goto(path);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) // WCAG 2.2 AA
.analyze();
expect(results.violations).toEqual([]);
});
}
// 开发模式:React 中的 axe-core(在控制台中显示违规)
// app/layout.tsx
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000);
});
}
模式 2:模态框的焦点管理
import { useEffect, useRef, useCallback } from 'react';
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// 保存当前焦点以便稍后恢复
previousFocusRef.current = document.activeElement as HTMLElement;
// 聚焦第一个可聚焦元素
const container = containerRef.current;
if (!container) return;
const focusable = container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
focusable[0]?.focus();
// 在容器内限制焦点
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
// 对背景内容使用 inert
const mainContent = document.querySelector('main');
mainContent?.setAttribute('inert', '');
return () => {
container.removeEventListener('keydown', handleKeyDown);
mainContent?.removeAttribute('inert');
previousFocusRef.current?.focus(); // 恢复焦点
};
}, [isOpen]);
return containerRef;
}
模式 3:动态内容的 ARIA 实时区域
// 向屏幕阅读器播报表单错误、加载状态和更新
function useAnnounce() {
const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
const el = document.getElementById(`aria-live-${priority}`);
if (el) {
el.textContent = ''; // 先清除以触发重新播报
requestAnimationFrame(() => { el.textContent = message; });
}
}, []);
return announce;
}
// 在布局中挂载一次:
function AriaLiveRegions() {
return (
<>
<div id="aria-live-polite" aria-live="polite" aria-atomic="true" className="sr-only" />
<div id="aria-live-assertive" aria-live="assertive" aria-atomic="true" className="sr-only" />
</>
);
}
┌─ 无障碍性测试金字塔 ────────────────────┐
│ │
│ ▲ 手动屏幕阅读器测试 │
│ ╱ ╲ (VoiceOver、NVDA — 每季度) │
│ ╱───╲ │
│ ╱ ╲ Playwright + axe-core E2E │
│ ╱ E2E ╲ (每个页面,CI 门禁) │
│ ╱─────────╲ │
│ ╱ ╲ jest-axe 组件测试 │
│ ╱ 组件 ╲ (每个可交互组件) │
│ ╱───────────────╲ │
│ ╱ ╲ ESLint jsx-a11y │
│╱ 静态 lint ╲ (每次提交) │
│╲___________________╱ │
└─────────────────────────────────────────────────────┘
反模式
- 使用
div和onClick代替button-- divs 没有键盘交互、没有角色、没有焦点。请使用语义化的<button>或<a>元素。如果必须使用 div,请添加role="button"、tabindex="0"以及 Enter/Space 键的onKeyDown处理。 - 到处使用
aria-label-- 过度标记会对屏幕阅读器用户造成噪音。优先使用可见的文本标签;仅在无法使用可见文本时使用aria-label。 - 隐藏焦点轮廓 -- 没有替代方案的
outline: none会使键盘导航不可见。使用:focus-visible为纯键盘操作提供焦点指示器,避免在鼠标点击时显示。 - 缺少跳过导航链接 -- 键盘用户每次都必须通过 Tab 键遍历整个导航。添加
<a href="#main" class="sr-only focus:not-sr-only">跳到主要内容</a>作为第一个可聚焦元素。 - 没有实时区域的 Toast 通知 -- 屏幕阅读器无法检测到动态添加的 Toast。使用
aria-live="polite"区域播报它们。
质量检查清单
- axe-core CI 门禁在所有页面上以零违规通过
-
eslint-plugin-jsx-a11y已启用且无警告 - 所有图像都有描述性的
alt文本(装饰性图像使用alt="") - 所有表单输入都有关联的
<label>元素(不仅仅是占位符文本) - 标题层级是连续的(
h1>h2>h3,无跳级) - 页面地标存在:
<main>、<nav>、<header>、<footer> - 跳过导航链接是第一个可聚焦元素
- 所有可交互元素上焦点可见(定义了
:focus-visible样式) - 模态框限制焦点并在关闭时恢复焦点
- 动态内容变化通过
aria-live区域播报 - 颜色对比度满足 WCAG AA(普通文本 4.5:1,大文本 3:1)
- 所有功能仅通过键盘即可操作(Tab、Enter、Space、Escape、方向键)