Files
NextEdu/docs/accessibility/a11y-audit.md

284 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 无障碍审计报告 (A11y Audit)
> 审计日期2026-06-17
> 审计范围:`src/shared/` 核心组件与新增无障碍工具
> 合规目标WCAG 2.1 AA
---
## 一、已审计组件与 ARIA 改进
### 1. 新增无障碍工具库
| 文件 | 导出 | 用途 |
|------|------|------|
| `src/shared/lib/a11y.ts` | `useA11yId` | 基于 `React.useId` 生成 SSR 安全的唯一 ID用于 `aria-describedby``aria-labelledby` |
| `src/shared/lib/a11y.ts` | `mergeA11yProps` | 合并多组 aria/data 属性,`aria-*`/`data-*` 字符串属性以空格拼接 |
| `src/shared/lib/a11y.ts` | `describeInput` | 计算输入框的 `aria-describedby``aria-invalid` |
| `src/shared/lib/a11y.ts` | `loadingAria` | 提供加载状态的 `aria-busy``aria-live` 属性 |
### 2. 新增 Hook
| 文件 | 导出 | 用途 |
|------|------|------|
| `src/shared/hooks/use-aria-live.ts` | `useAriaLive` | 管理 aria-live 区域,支持 polite/assertive 通知,自动清除过期通知(默认 5s返回 `{ announce, liveRegion }` |
### 3. 新增 a11y 组件
| 文件 | 组件 | 用途 |
|------|------|------|
| `src/shared/components/a11y/skip-link.tsx` | `SkipLink` | 跳转链接,视觉隐藏,获得焦点时高对比度显示,默认跳转 `#main-content` |
| `src/shared/components/a11y/visually-hidden.tsx` | `VisuallyHidden` | 视觉隐藏但屏幕阅读器可读,用于图标按钮文字描述、表单辅助说明 |
| `src/shared/components/a11y/focus-trap.tsx` | `FocusTrap` | 焦点陷阱,捕获 Tab/Shift+Tab 循环,支持初始焦点与焦点恢复 |
| `src/shared/components/a11y/aria-status.tsx` | `AriaStatus` | ARIA 状态通知区域,渲染 `aria-live` 区域,支持 polite/assertive |
### 4. 增强的核心 UI 组件
#### `src/shared/components/ui/table.tsx`
| 组件 | ARIA 改进 |
|------|-----------|
| `Table` | 默认 `role="table"`(可覆盖),支持 `aria-rowcount``aria-colcount` |
| `TableHeader` | 默认 `role="rowgroup"` |
| `TableBody` | 默认 `role="rowgroup"` |
| `TableFooter` | 默认 `role="rowgroup"` |
| `TableRow` | 默认 `role="row"` |
| `TableHead` | 默认 `role="columnheader"`,支持 `scope` 属性(`col`/`row`/`colgroup`/`rowgroup` |
| `TableCell` | 默认 `role="cell"` |
| `TableCaption` | 已有 `<caption>` 元素,为表格提供可访问标题 |
所有 `role` 均为默认值,可通过 props 覆盖,**完全向后兼容**。
#### `src/shared/components/ui/dialog.tsx`
| 改进项 | 说明 |
|--------|------|
| `aria-modal="true"` | 显式添加到 `DialogContent`Radix 已内置,此处显式标注便于审计) |
| 关闭按钮 `aria-label="关闭"` | 添加明确的中文无障碍标签 |
| 关闭按钮 sr-only 文本 | 由 "Close" 改为 "关闭",与项目语言一致 |
| 焦点管理 | Radix Dialog 原语已内置:打开时焦点移入内容区,关闭时恢复到触发元素 |
| Esc 键关闭 | Radix Dialog 原语已内置 |
| `aria-labelledby` | Radix 自动关联 `DialogTitle` 的 id 到 `aria-labelledby` |
---
## 二、待改进项
| 优先级 | 项目 | 说明 |
|--------|------|------|
| 高 | 表单组件 `aria-describedby` 关联 | `Input``Textarea``Select` 等需配合 `describeInput` 工具函数,将错误提示和帮助文本的 id 关联到输入框 |
| 高 | 图标按钮 `aria-label` | 全项目排查仅含图标无文字的按钮,补充 `aria-label` 或使用 `VisuallyHidden` |
| 中 | `Sheet`/`AlertDialog` 焦点管理 | 参照 `Dialog` 增强,显式添加 `aria-modal` 和中文关闭标签 |
| 中 | 数据表格 `aria-rowcount`/`aria-colcount` | 在使用 `@tanstack/react-table` 的页面中,为 `Table` 传入总行数和列数 |
| 中 | 面包屑 `aria-label="面包屑导航"` | `Breadcrumb` 容器添加 `nav``aria-label` |
| 中 | 分页组件 `aria-label` | 分页导航添加 `aria-label="分页"`,当前页使用 `aria-current="page"` |
| 低 | 动态内容变更播报 | 在表单提交、数据加载场景接入 `useAriaLive` 进行状态播报 |
| 低 | 颜色对比度审查 | 使用 axe DevTools 全量扫描颜色对比度是否达到 4.5:1正文/ 3:1大文字 |
| 低 | 跳转链接全局应用 | 将 `app/(dashboard)/layout.tsx` 中的内联 skip-link 替换为 `SkipLink` 组件 |
---
## 三、屏幕阅读器测试指南
### NVDAWindows免费
1. **安装**:从 [nvaccess.org](https://www.nvaccess.org/) 下载安装
2. **启动/退出**`Ctrl + Alt + N` 启动,`Insert + Q` 退出
3. **核心快捷键**
- `↓` / `↑`:逐行阅读
- `Tab` / `Shift + Tab`:在可聚焦元素间移动
- `H`:按标题跳转
- `T`:跳转到表格
- `F`:跳转到表单控件
- `B`:跳转到按钮
- `Insert + Tab`:播报当前焦点元素
- `Insert + Space`:切换浏览/焦点模式
4. **测试要点**
- 打开页面后 Tab 到 SkipLink确认可跳转到主内容区
- Tab 遍历所有交互元素,确认每个元素有可读的名称
- 打开 Dialog确认焦点移入对话框、Esc 可关闭、关闭后焦点回到触发按钮
- 在表格中按 `T` 跳转,确认表格标题和行列关系正确播报
### VoiceOvermacOS内置
1. **启动/退出**`Cmd + F5`
2. **核心快捷键**
- `Ctrl + Option + →` / `←`:逐元素导航
- `Ctrl + Option + Cmd + H`:按标题跳转
- `Ctrl + Option + Cmd + T`:跳转到表格
- `Ctrl + Option + Space`:激活当前元素
- `Ctrl + Option + U`打开转子Rotor按元素类型浏览
3. **测试要点**
- 确认 SkipLink 获得焦点时高对比度显示
- 确认 `aria-live` 区域在表单提交后播报结果
- 确认 `VisuallyHidden` 内容被播报但不可见
- 确认 Dialog 打开时 VoiceOver 朗读对话框标题
### 通用测试清单
- [ ] 所有交互元素可通过键盘访问Tab/Shift+Tab/Enter/Space/Esc
- [ ] 焦点顺序符合视觉阅读顺序
- [ ] 焦点可见focus 样式清晰)
- [ ] 每个交互元素有可访问名称(`aria-label` 或可见文字)
- [ ] 表单错误信息通过 `aria-live``aria-describedby` 播报
- [ ] 加载状态通过 `aria-busy``aria-live` 播报
- [ ] 模态框打开时焦点被困在框内,关闭后恢复
---
## 四、WCAG 2.1 AA 合规检查清单
### 原则一:可感知 (Perceivable)
| 准则 | 状态 | 说明 |
|------|------|------|
| 1.1.1 非文本内容 | ✅ | 图标按钮通过 `aria-label``VisuallyHidden` 提供文字替代 |
| 1.2.1 纯音频/视频 | ⚠️ | 项目暂无音视频内容,后续如需添加需提供字幕/文字稿 |
| 1.3.1 信息与关系 | ✅ | 表格通过 `role``scope` 表达行列关系;表单通过 `aria-describedby` 关联说明 |
| 1.3.2 有意义的顺序 | ✅ | DOM 顺序与视觉顺序一致 |
| 1.3.3 感官特征 | ✅ | 不仅依赖颜色/位置传达信息,配合文字说明 |
| 1.3.4 方向 | ✅ | 不限制屏幕方向 |
| 1.4.1 颜色的使用 | ✅ | 错误状态除颜色外配合文字/图标 |
| 1.4.3 对比度(最低) | ⚠️ | 需全量审查,语义色 `muted-foreground` 需确认对比度 ≥ 4.5:1 |
| 1.4.4 文字缩放 | ✅ | 使用 `rem`/`em` 单位,支持 200% 缩放 |
| 1.4.10 回流 | ✅ | 响应式布局,支持 320px 宽度 |
| 1.4.11 非文字对比度 | ✅ | 边框、焦点环使用语义色,对比度 ≥ 3:1 |
### 原则二:可操作 (Operable)
| 准则 | 状态 | 说明 |
|------|------|------|
| 2.1.1 键盘 | ✅ | 所有交互可通过键盘操作 |
| 2.1.2 无键盘陷阱 | ✅ | `FocusTrap` 仅在模态框激活时使用Esc 可退出 |
| 2.1.4 字符快捷键 | ✅ | 无单字符快捷键 |
| 2.2.1 计时可调 | ✅ | 无超时限制(会话超时由 NextAuth 管理,可延长) |
| 2.3.1 三次闪烁 | ✅ | 无闪烁内容 |
| 2.4.1 跳过区块 | ✅ | `SkipLink` 组件提供跳转到主内容 |
| 2.4.2 页面标题 | ✅ | Next.js metadata 提供页面标题 |
| 2.4.3 焦点顺序 | ✅ | DOM 顺序符合逻辑 |
| 2.4.4 链接目的 | ✅ | 链接文字描述目的,避免"点击这里" |
| 2.4.6 标题与标签 | ✅ | 表单字段使用 `Label` 组件关联 |
| 2.4.7 焦点可见 | ✅ | 所有交互元素有 `focus:ring` 样式 |
| 2.5.3 标签包含名称 | ✅ | 可见标签文字包含在可访问名称中 |
### 原则三:可理解 (Understandable)
| 准则 | 状态 | 说明 |
|------|------|------|
| 3.1.1 页面语言 | ✅ | `<html lang="zh-CN">` |
| 3.1.2 部分语言 | ✅ | 暂无混语言内容 |
| 3.2.1 聚焦 | ✅ | 聚焦不触发意外上下文变更 |
| 3.2.2 输入 | ✅ | 表单提交需明确按钮触发 |
| 3.2.3 一致导航 | ✅ | 侧边栏导航在页面间一致 |
| 3.2.4 一致标识 | ✅ | 功能相同的组件使用一致标识 |
| 3.3.1 错误识别 | ✅ | 表单错误通过 `aria-invalid``aria-describedby` 播报 |
| 3.3.2 标签或说明 | ✅ | 表单字段使用 `Label` 关联,提供 `placeholder` 补充 |
| 3.3.3 错误建议 | ⚠️ | 部分表单错误仅提示"必填",需补充修正建议 |
| 3.3.4 错误预防 | ✅ | 删除/提交关键操作使用 `AlertDialog` 确认 |
### 原则四:健壮 (Robust)
| 准则 | 状态 | 说明 |
|------|------|------|
| 4.1.1 解析 | ✅ | React 保证有效 HTML |
| 4.1.2 名称、角色、值 | ✅ | ARIA 角色和属性正确设置,状态变化通过 `aria-live` 播报 |
| 4.1.3 状态消息 | ✅ | `useAriaLive``AriaStatus` 提供 `aria-live` 状态播报 |
---
## 五、自动化测试工具推荐
| 工具 | 用途 | 链接 |
|------|------|------|
| axe DevTools | 浏览器插件,扫描页面无障碍问题 | https://www.deque.com/axe/devtools/ |
| Lighthouse | Chrome 内置,生成无障碍评分 | Chrome DevTools → Lighthouse |
| @axe-core/playwright | E2E 测试中集成 axe 检查 | https://github.com/dequelabs/axe-core-npm |
| eslint-plugin-jsx-a11y | ESLint 静态检查 JSX 无障碍问题 | https://github.com/jsx-eslint/eslint-plugin-jsx-a11y |
---
## 六、使用示例
### `useAriaLive` — 表单提交结果播报
```tsx
"use client"
import { useAriaLive } from "@/shared/hooks/use-aria-live"
function MyForm(): JSX.Element {
const { announce, liveRegion } = useAriaLive()
const handleSubmit = async (): Promise<void> => {
const result = await submitAction()
if (result.success) {
announce("保存成功", { politeness: "polite" })
} else {
announce(`保存失败:${result.message}`, { politeness: "assertive" })
}
}
return (
<>
<form onSubmit={handleSubmit}>{/* ... */}</form>
{liveRegion}
</>
)
}
```
### `describeInput` — 输入框错误关联
```tsx
import { useA11yId, describeInput } from "@/shared/lib/a11y"
function EmailField({ error }: { error?: string }): JSX.Element {
const hintId = useA11yId("email-hint")
const errorId = useA11yId("email-error")
const { ariaDescribedBy, ariaInvalid } = describeInput(
hintId,
error ? errorId : undefined
)
return (
<>
<Input
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
/>
<span id={hintId} className="text-muted-foreground text-sm">
</span>
{error && (
<span id={errorId} className="text-destructive text-sm" role="alert">
{error}
</span>
)}
</>
)
}
```
### `FocusTrap` — 自定义模态框
```tsx
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
interface CustomModalProps {
open: boolean
onClose: () => void
children: React.ReactNode
}
function CustomModal({ open, onClose, children }: CustomModalProps): JSX.Element {
return (
<FocusTrap active={open} restoreFocus>
<div role="dialog" aria-modal="true">
{children}
<button onClick={onClose}></button>
</div>
</FocusTrap>
)
}
```