
一句话:无障碍的第一原则是「能用原生 HTML 就用原生 HTML」(语义、键盘、屏幕阅读器全都白送),第二原则是「键盘能完成一切操作且焦点始终可见可知」——ARIA 只是「原生表达不了时的补丁」,而不是「装饰性的属性堆砌」。
一、标准:POUR 四原则与三个等级
⭐⭐ WCAG 的四条原则(POUR):
① 【可感知 Perceivable】——信息必须能「被感知」
· 图片有替代文本、视频有字幕、颜色不是唯一的信息载体、对比度足够
② 【可操作 Operable】——界面必须能「被操作」
· ⭐ 所有功能都能用键盘完成、焦点可见、不设时间陷阱、避免闪烁
③ 【可理解 Understandable】——内容与操作必须「可理解」
· 语言声明、一致的导航、输入错误有明确提示与纠正建议
④ 【健壮 Robust】——必须能被「各种辅助技术」正确解析
· ⭐ 用标准语义(原生元素/正确的 ARIA 角色与状态)
⭐ 等级:**A(最低)→ AA(普遍要求,多数法规/采购以此为线)→ AAA(更高)**
⭐ 实践的默认目标是「**WCAG 2.2 AA**」。
⭐⭐ WCAG 2.2 新增的几条(常被问到,且很实用):
· 【Focus Not Obscured】获得焦点的元素不能被「粘性头部/浮层」完全遮住
· 【Dragging Movements】拖拽必须提供「非拖拽的替代操作」(见 1.16)
· 【Target Size (Minimum)】可点击目标至少 24×24 CSS px(AA)
· 【Consistent Help】帮助入口的位置要一致
· 【Accessible Authentication】不要求「记忆/转写」作为唯一认证手段
二、语义化 HTML:90% 的问题在写 HTML 时就解决了
<!-- ❌ 典型反例:全是 div(屏幕阅读器只能读出「一堆文本」) -->
<div class="header">
<div class="nav">
<div class="nav-item" onclick="go('/')">首页</div>
<div class="nav-item" onclick="go('/about')">关于</div>
</div>
</div>
<div class="content">
<div class="title">产品介绍</div>
<div class="text">...</div>
</div>
<!-- ✅ 语义化:屏幕阅读器能「跳转」到导航/主内容,键盘能 Tab 到链接,ESC/Enter 天然可用 -->
<header>
<nav aria-label="主导航">
<ul>
<li><a href="/">首页</a></li>
<li><a href="/about">关于</a></li>
</ul>
</nav>
</header>
<main id="main">
<h1>产品介绍</h1> <!-- ⭐ 页面唯一 h1,标题层级不跳级 -->
<p>...</p>
</main>
<footer>…</footer>
⭐⭐⭐ 「用原生元素」白送的六件事(用 div + onclick 要自己补全部):
<button> <div role="button" tabindex="0">
───────────────────────────── ────────────────────────────────
✅ 可聚焦(Tab) ❌ 要加 tabindex
✅ Enter/Space 触发 ❌ 要手写 keydown 处理(且空格要 preventDefault)
✅ role=button(自带) ❌ 要手写 role
✅ 语音控制可说「点击提交」 ⚠️ 可能要说「点击 div」
✅ 表单内 submit 语义 ❌ 完全没有
✅ focus/active/hover 样式默认可用 ❌ 要自己写
✅ 被 AT 正确播报为「按钮」 ❌ 可能被播报为「分组」
⭐ 结论:「**能用原生就别用 ARIA**」——这条(WAI-ARIA 第一条规则)能省掉 80% 的工作。
| 你要做的 | 用这个原生元素 | 不要这样 |
|---|---|---|
| 按钮 | <button> |
<div role="button"> |
| 链接(跳转) | <a href> |
<span onclick> |
| 折叠/展开 | <details>/<summary> |
<div> + aria-expanded 全套 |
| 对话框 | <dialog>(showModal()) |
<div role="dialog"> + 手写焦点陷阱 |
| 进度 | <progress> |
<div> + aria-valuenow |
| 开关 | <input type="checkbox"> |
<div role="switch"> |
| 分组表单 | <fieldset>/<legend> |
<div> + aria-labelledby |
| 提示(无需交互) | title 属性 / 文本 |
<div role="tooltip">(复杂时) |
| 搜索 | <form role="search"> + <input type="search"> |
<div> 拼装 |
⭐⭐ ARIA 的五条规则(WAI-ARIA Authoring Practices 的「使用规则」):
① 【能用原生元素/属性实现,就不要用 ARIA】⭐ 最重要
② 【不要改变原生语义】(除非真的必要)——如 `<h1 role="button">` 是坏味道
③ 【所有可交互的 ARIA 控件都必须能用键盘操作】
④ 【不要对「可见且可访问的元素」用 `aria-hidden="true"`】(也不要给可聚焦元素加)
⚠️ 后果:「屏幕上能看到,但屏幕阅读器读不到」或「聚焦到一个『不存在』的元素」
⑤ 【所有 ARIA 元素必须有「可访问的名字」】
✅ `aria-label` / `aria-labelledby` / 元素内容(`<button>保存</button>` 本身就是名字)
⭐⭐⭐ 「可访问名字(Accessible Name)」的计算优先级(面试常问):
① `aria-labelledby`(引用其它元素的 id)—— 优先级最高
② `aria-label`(直接给字符串)
③ **元素自身的文本内容**(`<button>保存</button>` → 名字是「保存」)
④ `<label for>` / `<label>` 包裹(表单元素)
⑤ `title` 属性 —— 兜底(⭐ 不要依赖它:它也是「悬停提示」,会被屏幕阅读器「降级处理」)
⭐ 实践建议:
· 按钮/链接:**用文本内容**(最稳)
· 图标按钮:`aria-label="关闭"`(⭐ 必须!否则读作「按钮」)
· 表单输入:`<label for>`
· 区域/导航:`aria-label` 或 `aria-labelledby`
// ✅ 图标按钮的正确写法(这是最常见的无障碍缺陷)
// ❌ 错误:屏幕阅读器只读「按钮」,用户不知道点它会干嘛
<button onClick={onClose}><CloseIcon /></button>
// ✅ 正确
<button onClick={onClose} aria-label="关闭">
<CloseIcon aria-hidden="true" /> {/* ⭐ 图标是装饰 → 从无障碍树里移除 */}
</button>
// ✅ 或者用「视觉隐藏但可读」的文本(不需要 aria-label)
<button onClick={onClose}>
<CloseIcon aria-hidden="true" />
<span className="sr-only">关闭</span>
</button>
/* ⭐ sr-only:视觉隐藏但「屏幕阅读器可读」(≠ display: none) */
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* ⚠️ 不要用 `display: none` 或 `visibility: hidden`(那会「从无障碍树里也移除」) */
三、键盘可达性:四条硬要求
⭐⭐⭐⭐ 键盘的「四条硬要求」(不满足就是「键盘用户无法使用」):
① 【能用 Tab 到达所有可交互元素】
· 原生可聚焦元素(`a[href]`/`button`/`input`/`select`/`textarea`/[tabindex])天然可 Tab
· ❌ `div onclick` 不可 Tab(必须加 `tabindex="0"` + `role` + 键盘处理 —— 见上面的表)
· ⚠️ `tabindex="0"` 是「加入 Tab 顺序(按 DOM 顺序)」;
`tabindex="-1"` 是「可编程聚焦但不进 Tab 顺序」(⭐ 用于「把焦点移到某个容器」)
`tabindex > 0` ⭐ **几乎总是错的**(会打乱 DOM 顺序,维护噩梦)
· `inert` 属性:让整棵子树「不可聚焦 + 从无障碍树移除」(⭐ 比 `aria-hidden` + `pointer-events: none` 更安全)
② 【焦点始终可见】
· ⭐ 用 `:focus-visible` 给一个「清晰的轮廓」(见 10.8)
· ❌ 绝对不能 `outline: none` 而不给替代(这是最严重的无障碍问题之一)
· ⚠️ 浮层/粘性头部不能**完全遮住**获得焦点的元素(WCAG 2.2)
③ 【键盘能完成「一切」操作】
· 点击能做的,键盘也能做(包括拖拽 —— 见 1.16)
· 常见快捷键约定:`Enter`/`Space` 激活、`Esc` 关闭、
`↑↓` 列表内移动(用 `aria-activedescendant` 或 roving tabindex)、
`←→` 标签页切换、`Home`/`End` 首尾
④ 【焦点顺序合理】
· 默认按 DOM 顺序(✅ 所以「DOM 顺序 = 视觉顺序 = 逻辑顺序」是根本原则)
· ⚠️ 用 CSS 的 `order`/`flex-direction: row-reverse`/`grid` 重排时,
**视觉顺序与 DOM 顺序不一致** → 键盘顺序会「跳来跳去」
· ✅ 解法:改 DOM 顺序(而不是改 CSS 顺序);或接受并测一遍
// ✅ SkipLink:给键盘用户一个「跳过导航直达主内容」的入口
export function SkipLink({ targetId = 'main' }: { targetId?: string }) {
return (
<a
href={`#${targetId}`}
style={{
position: 'absolute', left: 8, top: 8,
// ⭐ 默认「移出视口」(视觉隐藏,但仍可聚焦)
transform: 'translateY(-200%)',
padding: '8px 12px', background: '#fff', color: '#000',
border: '2px solid currentColor', borderRadius: 6, zIndex: 10000,
transition: 'transform 120ms',
}}
onFocus={(e) => { e.currentTarget.style.transform = 'translateY(0)'; }}
onBlur={(e) => { e.currentTarget.style.transform = 'translateY(-200%)'; }}
>
跳到主要内容
</a>
);
}
// ⚠️ 必须放在「body 的最前面」(第一个可聚焦元素)
// ⚠️ 目标元素需要 `tabIndex={-1}` 才能被「聚焦」(否则只是滚动过去)
// <main id="main" tabIndex={-1}>…</main>
// ✅ 焦点陷阱(Modal 用):Tab 只在浮层内循环
export function useFocusTrap<T extends HTMLElement>(
active: boolean,
options: { initialFocus?: () => HTMLElement | null; restoreFocus?: boolean } = {}
) {
const ref = useRef<T | null>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!active) return;
const container = ref.current;
if (!container) return;
// ⭐ 记住「打开前的焦点」,关闭时还回去
previouslyFocused.current = document.activeElement as HTMLElement | null;
const getFocusable = (): HTMLElement[] =>
Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.offsetParent !== null || el === document.activeElement); // ⭐ 过滤「不可见」的
// ⭐ ① 初始聚焦(优先「业务指定」→ 第一个可聚焦元素 → 容器本身)
const initial = options.initialFocus?.() ?? getFocusable()[0] ?? container;
// ⚠️ 用 rAF 确保「浮层已完成布局」(否则聚焦可能失败)
requestAnimationFrame(() => initial.focus());
// ⭐ ② 拦截 Tab(首尾循环)
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = getFocusable();
if (focusable.length === 0) { e.preventDefault(); container.focus(); return; }
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
const current = document.activeElement;
// 首部 Shift+Tab → 跳到最后
if (e.shiftKey && (current === first || current === container)) {
e.preventDefault();
last.focus();
}
// 尾部 Tab → 跳到第一个
else if (!e.shiftKey && current === last) {
e.preventDefault();
first.focus();
}
};
// ⭐ ③ 焦点「意外逃出」时拉回来(如程序性 focus 到外部)
const onFocusIn = (e: FocusEvent) => {
if (!container.contains(e.target as Node)) {
getFocusable()[0]?.focus();
}
};
document.addEventListener('keydown', onKeyDown, true);
document.addEventListener('focusin', onFocusIn);
return () => {
document.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('focusin', onFocusIn);
// ⭐ 关闭时「把焦点还给打开它的那个元素」
if (options.restoreFocus !== false) previouslyFocused.current?.focus();
};
}, [active, options]);
return ref;
}
四、屏幕阅读器:三个必须做对的地方
⭐⭐⭐ ① 【动态内容要「播报」】(aria-live)
问题:React 更新 DOM 时,屏幕阅读器**不会主动播报**(用户不知道发生了什么)
✅ 解法:把「需要播报的消息」放进一个 `aria-live` 容器
· `role="status"` = `aria-live="polite"`(⭐ 等用户空闲时播报)
· `role="alert"` = `aria-live="assertive"`(⭐ 立刻打断播报,⚠️ 少用)
· `aria-atomic="true"` → 播报「整个区域」而不是只播报变化的部分
⚠️ 容器必须「一开始就在 DOM 里」(先有容器、后改内容)——
「插入一个带 aria-live 的新元素」通常**不会**被播报
⭐⭐⭐ ② 【图标/装饰内容要从无障碍树里移除】(aria-hidden)
<span aria-hidden="true">▾</span> {/* 装饰箭头 */}
❌ 不要给「可聚焦元素」加 aria-hidden
⚠️ 不要给「包含可聚焦子元素」的容器加 aria-hidden(会形成「可聚焦但不可见」的陷阱)
⭐⭐⭐ ③ 【状态的表达要用「状态属性」而不是「视觉类名」】
❌ <div className="active">当前页</div>
✅ <a aria-current="page">当前页</a>
✅ <button aria-expanded="true">已展开</button>
✅ <input aria-invalid="true" aria-describedby="err-1" />
✅ <div role="progressbar" aria-valuenow={30} aria-valuemin={0} aria-valuemax={100} />
// ✅ 一个可用的「播报器」(全局单例 + Hook)
let container: HTMLElement | null = null;
function ensureContainer() {
if (container) return container;
container = document.createElement('div');
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-atomic', 'true');
// ⭐ 视觉隐藏但「留在无障碍树里」
container.className = 'sr-only';
container.id = 'a11y-announcer';
document.body.appendChild(container);
return container;
}
export function announce(message: string, priority: 'polite' | 'assertive' = 'polite') {
if (typeof document === 'undefined') return;
const el = ensureContainer();
el.setAttribute('aria-live', priority);
// ⚠️ 关键:**先清空再写入**(否则「内容没变」时不会重新播报)
el.textContent = '';
// ⭐ 用 rAF/微任务让「清空」与「写入」成为两次变更
setTimeout(() => { el.textContent = message; }, 30);
}
/** ⭐ 在 React 里用(配合「操作结果」播报) */
export function useAnnounce() {
return useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
announce(message, priority);
}, []);
}
// 用法:拖拽排序后
announce(`已移动到第 ${index + 1} 位,共 ${total} 项`);
// 用法:表单提交失败
announce('提交失败:请检查表单中的错误', 'assertive');
// ✅ 一个「无障碍完整」的表单字段(label / 描述 / 错误三者关联)
export function Field({
id: idProp,
label,
description,
error,
required,
...inputProps
}: {
id?: string;
label: string;
description?: string;
error?: string;
required?: boolean;
} & React.InputHTMLAttributes<HTMLInputElement>) {
const autoId = useId();
const id = idProp ?? autoId;
const descId = description ? `${id}-desc` : undefined;
const errId = error ? `${id}-err` : undefined;
return (
<div>
<label htmlFor={id}>
{label}
{/* ⭐ 必填用「文本」而不是只用星号(星号对屏幕阅读器没意义) */}
{required && <span aria-hidden="true"> *</span>}
{required && <span className="sr-only">(必填)</span>}
</label>
{description && <p id={descId}>{description}</p>}
<input
{...inputProps}
id={id}
required={required}
// ⭐ 状态:「无效」用 aria-invalid,而不是只加红色边框
aria-invalid={error ? true : undefined}
// ⭐ 把「描述」与「错误」都关联上(屏幕阅读器会一起读)
aria-describedby={[descId, errId].filter(Boolean).join(' ') || undefined}
/>
{/* ⭐ 错误用 role="alert"(立刻播报)+ 文本说明(不只是颜色) */}
{error && (
<p id={errId} role="alert" style={{ color: 'var(--color-text-danger)' }}>
{error}
</p>
)}
</div>
);
}
五、视觉无障碍:对比度与偏好
⭐⭐⭐ 三件事:
① 【对比度】(WCAG AA)
· 普通文本:**≥ 4.5:1**
· 大文本(≥18.66px 粗体 或 ≥24px):**≥ 3:1**
· **UI 组件边界与图形**(如图标、输入框边框、焦点轮廓):**≥ 3:1**
⚠️ 最常见的缺陷:「浅灰占位符文字」「禁用态文字」「浅色主题下的次要文字」
✅ 工具:浏览器 DevTools 的「对比度」显示;或 axe / Lighthouse
② 【不要把颜色当成唯一的信息载体】
❌ 「红色表示错误」(色盲用户看不出)
✅ 「红色 + 图标 + 文本」(三重表达)
❌ 「只靠颜色区分图表系列」
✅ 「颜色 + 图案/形状 + 图例文字」
③ 【尊重用户偏好】
· `prefers-reduced-motion: reduce` → 降低/取消动画(见 1.15)
· `prefers-contrast: more` → 提高对比度
· `prefers-color-scheme` → 明暗主题(见 10.8)
· `forced-colors`(Windows 高对比模式)→ ⭐ 不要用硬编码颜色,用系统颜色关键字
/* ✅ 尊重「减少动态效果」 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ✅ 尊重「更高对比度」 */
@media (prefers-contrast: more) {
:root {
--color-border: #000;
--color-text-secondary: #333;
}
.btn { border-width: 2px; }
}
/* ✅ 尊重「强制颜色模式(Windows 高对比)」(用系统颜色关键字) */
@media (forced-colors: active) {
.card {
/* ⭐ 不要硬编码颜色,用系统关键字(浏览器会替换成用户的配色) */
border: 1px solid CanvasText;
background: Canvas;
color: CanvasText;
/* ⚠️ 用 box-shadow 做的「边框」在强制颜色下会消失 → 用真实 border */
}
/* ⭐ 图标用 currentColor 才能在强制颜色下跟随文字颜色 */
.icon { fill: currentColor; }
}
六、测试:四层方法(只有第一层能自动化)
⭐⭐⭐ 无障碍测试的四层(覆盖率互补,缺一不可):
① 【自动工具】(能覆盖约 **30~50%** 的问题)
· axe-core(jest-axe / @axe-core/playwright)、Lighthouse、Pa11y
· 能查:缺 label、对比度、role 错误、重复 id、缺失 lang…
· ⭐ 集成到 CI(每次 PR 自动跑),但**绝不能只靠它**
② 【键盘走查】(成本低、收益极高)⭐
· 只用 Tab/Shift+Tab/Enter/Space/Esc/方向键,走完「主要流程」
· 检查:能否到达所有交互元素、焦点是否可见、顺序是否合理、
能否完成「提交表单」「关闭弹窗」「拖拽排序」
③ 【屏幕阅读器实测】(成本中、能发现语义问题)
· macOS: VoiceOver(⌘F5);Windows: NVDA(免费);iOS: VoiceOver
· ⭐ 重点听:按钮/链接是否被正确播报、表单错误是否被播报、
动态内容(toast/加载完成/拖拽结果)是否被播报
④ 【真实用户测试】(成本高、最有价值)
· ⭐ 只有「用辅助技术的真实用户」能发现「能操作但很难用」的问题
// ✅ 把 axe 集成到测试里(Vitest + jsdom)
// setup.ts
import { expect } from 'vitest';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
// Field.test.tsx
import { render } from '@testing-library/react';
import { describe, it } from 'vitest';
describe('Field', () => {
it('没有无障碍违规', async () => {
const { container } = render(<Field label="邮箱" required description="用于登录" />);
expect(await axe(container)).toHaveNoViolations();
});
it('错误能被屏幕阅读器关联', () => {
const { getByLabelText, getByRole } = render(
<Field label="邮箱" error="格式不正确" />
);
// ⭐ 通过 label 找到输入(这本身就在校验「label 关联正确」)
const input = getByLabelText(/邮箱/);
// ⭐ 错误用 role="alert"(可被播报)
expect(getByRole('alert')).toHaveTextContent('格式不正确');
// ⭐ 且输入框通过 aria-describedby 关联了错误
expect(input).toHaveAttribute('aria-describedby', expect.stringContaining('-err'));
});
});
七、完整实现:一个无障碍的 Modal(230 行)
// AccessibleModal.tsx —— 把所有无障碍要点集成到一个组件里
import { useRef, useEffect, useId, useState } from 'react';
import { createPortal } from 'react-dom';
import { useFocusTrap } from './useFocusTrap';
import { usePortalContainer } from './portal'; // 见 1.12
import { useScrollLock } from './useScrollLock';
export function AccessibleModal({
open,
onClose,
title,
description,
children,
/** 是否「必须做出选择」(不能点背景关闭) */
dismissible = true,
}: {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children: React.ReactNode;
dismissible?: boolean;
}) {
const container = usePortalContainer('modal');
const titleId = useId();
const descId = useId();
const panelRef = useRef<HTMLDivElement | null>(null);
// ⭐ 焦点陷阱(含「关闭后焦点还原」)
useFocusTrap<HTMLDivElement>(open, { restoreFocus: true });
// ⚠️ 上面这个 Hook 需要把 ref 绑到容器上(这里演示用另一种写法)
const trapRef = useFocusTrap<HTMLDivElement>(open);
// ⭐ 滚动锁(用「计数器」,支持嵌套弹窗,见 1.12)
useScrollLock(open);
// ⭐ ESC 关闭(层叠时只关最上层 —— 真实项目用「浮层栈」,见 1.12)
useEffect(() => {
if (!open || !dismissible) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
};
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [open, dismissible, onClose]);
// ⭐⭐ 关键:把「弹窗之外的整棵应用」设为 inert
// —— 这是「最安全」的背景隔离方式:
// 它同时做到「不可聚焦」「不可点击」「从无障碍树移除」
useEffect(() => {
if (!open) return;
const root = document.getElementById('root') ?? document.body;
const siblings = Array.from(root.children).filter((el) => el !== container);
siblings.forEach((el) => el.setAttribute('inert', ''));
// ⚠️ 兼容性兜底:老浏览器不支持 inert → 用 aria-hidden + 阻止焦点
return () => { siblings.forEach((el) => el.removeAttribute('inert')); };
}, [open, container]);
if (!open || !container) return null;
return createPortal(
<>
{/* ⭐ 遮罩:点击关闭(但只在 dismissible 时) */}
{dismissible && (
<div
onClick={onClose}
aria-hidden="true" // ⭐ 遮罩是「装饰」→ 不参与无障碍树
style={{
position: 'fixed', inset: 0, zIndex: 'var(--z-backdrop, 2000)',
background: 'rgba(0,0,0,.45)',
}}
/>
)}
<div
ref={trapRef}
role="dialog"
// ⭐⭐ aria-modal="true" 告诉辅助技术「这是模态,外面的内容不可用」
aria-modal="true"
// ⭐ 用 aria-labelledby 指向标题(而不是 aria-label 写死字符串)
aria-labelledby={titleId}
aria-describedby={description ? descId : undefined}
style={{
position: 'fixed', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 'var(--z-modal, 2100)',
background: 'var(--color-bg-surface)',
color: 'var(--color-text-primary)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-6)',
minWidth: 320, maxWidth: 'min(560px, 92vw)',
maxHeight: '85vh', overflowY: 'auto',
}}
>
{/* ⭐ h2 与 aria-labelledby 关联(标题层级也要正确) */}
<h2 id={titleId} style={{ marginTop: 0 }}>{title}</h2>
{description && <p id={descId} style={{ color: 'var(--color-text-secondary)' }}>{description}</p>}
{children}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
<button onClick={onClose}>关闭</button>
</div>
</div>
</>,
container
);
}
/** ============ 一个「无障碍的 Tabs」(roving tabindex 模式) ============ */
export function Tabs({
tabs,
value,
onChange,
}: {
tabs: Array<{ id: string; label: string; content: React.ReactNode }>;
value: string;
onChange: (id: string) => void;
}) {
const listRef = useRef<HTMLDivElement | null>(null);
/** ⭐ 键盘:左右键在标签间移动(roving tabindex),Home/End 跳首尾 */
const onKeyDown = (e: React.KeyboardEvent) => {
const index = tabs.findIndex((t) => t.id === value);
let next = -1;
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
else return;
e.preventDefault(); // ⭐ 阻止「页面横向滚动」
const nextId = tabs[next]!.id;
onChange(nextId);
// ⭐ 移动后把焦点移到新的标签上(否则焦点还在原地)
requestAnimationFrame(() => {
listRef.current
?.querySelector<HTMLElement>(`[data-tab="${CSS.escape(nextId)}"]`)
?.focus();
});
};
const active = tabs.find((t) => t.id === value);
return (
<div>
<div ref={listRef} role="tablist" aria-label="内容切换" onKeyDown={onKeyDown}>
{tabs.map((tab) => {
const selected = tab.id === value;
return (
<button
key={tab.id}
data-tab={tab.id}
role="tab"
id={`tab-${tab.id}`}
// ⭐ roving tabindex:只有「选中的」在 Tab 顺序里,其余用方向键访问
tabIndex={selected ? 0 : -1}
aria-selected={selected}
// ⭐ 关联面板
aria-controls={`panel-${tab.id}`}
onClick={() => onChange(tab.id)}
style={{ borderBottom: selected ? '2px solid var(--color-primary)' : 'none' }}
>
{tab.label}
</button>
);
})}
</div>
{active && (
<div
role="tabpanel"
id={`panel-${active.id}`}
aria-labelledby={`tab-${active.id}`}
// ⭐ 面板需要可聚焦(否则键盘用户无法「进入面板内容」阅读)
tabIndex={0}
style={{ paddingTop: 12 }}
>
{active.content}
</div>
)}
</div>
);
}
⭐⭐⭐ 这两个组件演示了四个「进阶但必要」的无障碍模式:
① 【`inert` 做背景隔离】(比 `aria-hidden` + `pointer-events: none` 更彻底)
它同时做到「不可聚焦 + 不可点击 + 从无障碍树移除」——
⭐ 避免「Tab 跑到背景内容上」这个最常见的模态缺陷。
② 【`aria-modal` + `aria-labelledby` + `aria-describedby`】
· `aria-modal="true"` 明确「模态」语义
· 标题/描述用「引用」(而不是把字符串写进 `aria-label`)——
⭐ 好处:文案改了不用同步改 aria-label(单一事实源)
③ 【roving tabindex(Tabs 的标准模式)】
只有「当前选中项」在 Tab 顺序里(`tabIndex=0`),其余是 `-1`,
用**方向键**在它们之间移动。
⭐ 这样「Tab 一次就能跳过整组标签」(而不是「按 8 次 Tab 才走到下一个控件」)。
④ 【`role="tabpanel"` 要 `tabIndex={0}`】
因为面板里可能「没有可聚焦元素」(纯文本)——
不给它 `tabIndex`,键盘用户**无法「进入」面板滚动/阅读**。
八、本篇特有的坑
// ① 用 `div` + `onclick` 做按钮(键盘无法使用)
<div className="btn" onClick={save}>保存</div> // ❌
// ✅ <button onClick={save}>保存</button>
// ② `outline: none` 去掉焦点轮廓且不给替代(键盘用户「迷路」)
*:focus { outline: none; } // ❌❌
// ✅ :focus-visible + 清晰的 outline
// ③ 图标按钮没有可访问名字(读作「按钮」)
<button onClick={x}><IconClose /></button> // ❌
// ✅ aria-label="关闭"
// ④ `aria-hidden="true"` 加在「包含可聚焦元素」的容器上
<div aria-hidden="true"><button>提交</button></div> // ❌ 可聚焦但不可见
// ✅ 用 `inert`(或把焦点移走,或改成 display:none)
// ⑤ 用 `display: none` 做「视觉隐藏的辅助文本」(屏幕阅读器也读不到)
.sr-only { display: none; } // ❌
// ✅ clip + position: absolute(见上面的 sr-only)
// ⑥ 弹窗打开后「焦点没被移进去」(用户还在背景上 Tab)
// ✅ useFocusTrap:初始聚焦 + 拦住 Tab
// ⑦ 弹窗关闭后「焦点丢了」(回到 body)→ 用户「不知道自己在哪」
// ✅ 记住打开前的 activeElement,关闭时 focus 回去
// ⑧ 背景内容仍可被 Tab 到(模态缺陷)
// ✅ `inert` 或 `aria-hidden` + 真正的焦点陷阱
// ⑨ 动态内容不播报(toast/加载完成/错误)
// ✅ aria-live 容器 + announce()
// ⑩ 用 `aria-live` 但「容器是后插入的」(不会被播报)
// ✅ 容器一开始就在 DOM 里(本实现的 ensureContainer 是「懒创建 + 复用」,
// 所以第一次播报可能失效 → ⭐ 更好的做法是「在应用入口就创建好」)
// ⑪ 「错误只用颜色表达」
<span style={{ color: 'red' }}>{error}</span> // ⚠️ 色盲用户看不出
// ✅ 颜色 + 图标 + 文本 + aria-invalid
// ⑫ 表单输入没有关联 label(屏幕阅读器读作「编辑框」)
<input placeholder="请输入邮箱" /> // ⚠️ placeholder 不是 label
// ✅ <label htmlFor> + 关联(placeholder 只能作「补充」)
// ⑬ 必填只用一个星号
<label>邮箱 *</label> // ⚠️ 「*」对 AT 无意义
// ✅ `required` 属性 + `<span class="sr-only">(必填)</span>`
// ⑭ `tabindex="1"` 之类的大于 0(打乱 DOM 顺序)
// ✅ 只用 0 与 -1;调整顺序靠「改 DOM 顺序」
// ⑮ 用 CSS `order`/`row-reverse` 重排(视觉与 DOM 顺序不一致)
// ⚠️ 键盘顺序会「跳来跳去」;屏幕阅读器读的顺序也与视觉不符
// ✅ 改 DOM 顺序
// ⑯ 「自动聚焦」抢走用户的焦点(页面加载就把焦点抢到搜索框)
// ⚠️ 会让屏幕阅读器用户「错过前面的内容」
// ✅ 只在「明确需要」时 autoFocus(如表单校验失败后聚焦第一个错误字段)
// ⑰ 拖拽没有键盘替代(WCAG 2.2 明确要求)
// ✅ 见 1.16(空格拾起 + 方向键 + 上移/下移按钮)
// ⑱ 「点击目标太小」(移动端误触)
// ⚠️ WCAG 2.2 要求 ≥ 24×24(AA);实际建议 ≥ 44×44
// ✅ 用 padding 扩大「点击热区」而不是只放大图标
// ⑲ 粘性头部「遮住」获得焦点的元素(WCAG 2.2 明确点名)
// ✅ 给可聚焦元素加 `scroll-margin-top: var(--sticky-header-height)`(见 1.14)
// ⑳ 只在「最终开发完」才做无障碍(返工成本极高)
// ⭐ ✅ 正确做法:① 用「语义化 HTML」写(白送大半)② Lint 约束(eslint-plugin-jsx-a11y)
// ③ CI 跑 axe ④ 键盘走查纳入 DoD
// ㉑ `role="alert"` 用得太频繁(每次输入都「立刻打断」播报)
// ⚠️ assertive 会打断用户当前正在听的内容
// ✅ 只有「错误/紧急」用 assertive;「成功/一般状态」用 polite
// ㉒ 忽略「屏幕阅读器的『浏览模式』与『表单模式』」
// ⭐ 在浏览模式下「方向键是读内容」;在表单模式下「方向键是操作控件」
// → 自定义控件(如用 div 做的列表)行为要与「模式预期」一致
// ⑲ 的完整处理(焦点不被粘性头部遮住)
// 全局样式:
// :root { --sticky-header-height: 64px; }
// a:focus-visible, button:focus-visible, input:focus-visible, [tabindex]:focus-visible {
// scroll-margin-top: calc(var(--sticky-header-height) + 12px);
// }
// ⭐ `scroll-margin-top` 让「浏览器滚动到该元素」时留出空间(不会被吸顶头挡住)
// ⑳ 的完整处理(把 a11y 纳入「完成定义」)
// .eslintrc / eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default [
{
plugins: { 'jsx-a11y': jsxA11y },
rules: {
...jsxA11y.flatConfigs.recommended.rules,
// ⭐ 把最关键的几条「升级为 error」
'jsx-a11y/alt-text': 'error',
'jsx-a11y/anchor-is-valid': 'error',
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/no-static-element-interactions': 'error',
'jsx-a11y/label-has-associated-control': 'error',
'jsx-a11y/no-autofocus': 'warn',
},
},
];
九、面试延伸
- 「WCAG 的四条原则是什么?」
POUR:① 可感知(Perceivable)——信息必须能被感知(图片有替文本、视频有字幕、颜色不是唯一信息载体、对比度足够);② 可操作(Operable)——⭐ 所有功能都能用键盘完成、焦点可见、不设时间陷阱、避免闪烁;③ 可理解(Understandable)——语言声明、导航一致、错误有明确提示与纠正建议;④ 健壮(Robust)——⭐ 用标准语义(原生元素或正确的 ARIA 角色与状态)以便各种辅助技术解析。等级是 A → AA(多数法规/采购的线) → AAA,实践目标是「WCAG 2.2 AA」。 -
「为什么说『能用原生 HTML 就别用 ARIA』?」
因为原生元素白送六件事:① 可聚焦(Tab);② 键盘激活(Enter/Space,且空格会自动preventDefault);③ 正确的role(被播报为「按钮」);④ 语音控制友好(能说「点击提交」);⑤ 表单语义(表单内submit、label关联、required校验);⑥ 默认的focus/active/hover样式行为。而用<div role="button">要全部自己补,补漏一项就是缺陷。⭐ 这就是 WAI-ARIA 第一条规则:「如果原生 HTML 元素或属性能实现相同的语义与行为,就不要用 ARIA」——ARIA 只是「原生表达不了时的补丁」。 -
「ARIA 的五条规则是什么?」
① ⭐ 能用原生实现就别的用 ARIA;② 不要改变原生语义(如<h1 role="button">是坏味道);③ ⭐ 所有可交互的 ARIA 控件都必须能用键盘操作;④ ⭐ 不要对「可见且可访问的元素」用aria-hidden="true"(也不要给可聚焦元素加——会形成「能聚焦但不可见」的陷阱,正确的做法是inert);⑤ 所有 ARIA 元素必须有「可访问的名字」(aria-labelledby>aria-label> 内容 >label>title)。⭐ 实践总结:「ARIA 的第一规则是不用 ARIA」。 -
「可访问名字(Accessible Name)是怎么算的?」
优先级:①aria-labelledby(引用其它元素的 id)——最高;②aria-label(直接字符串);③ ⭐ 元素自身的文本内容(<button>保存</button>→ 名字是「保存」);④<label for>/<label>包裹(表单元素);⑤title——兜底(⚠️ 不要依赖:它同时是「悬停提示」,且屏幕阅读器的处理不一致)。实践建议:按钮/链接用文本内容(最稳);图标按钮必须给aria-label(否则读作「按钮」);表单输入用<label for>;区域/导航用aria-label或aria-labelledby。 -
「键盘可达性有哪些硬要求?」
四条:① ⭐ 能用 Tab 到达所有可交互元素——原生可聚焦元素天然可以,div onclick必须加tabindex="0"+role+ 键盘处理(所以直接用<button>);⚠️tabindex > 0几乎总是错的(打乱 DOM 顺序);② ⭐ 焦点始终可见——用:focus-visible给清晰轮廓,绝不能outline: none而不给替代,且浮层/粘性头部不能「完全遮住」获得焦点的元素(WCAG 2.2,用scroll-margin-top解决);③ ⭐ 键盘能完成一切操作(包括拖拽——见 1.16);④ 焦点顺序合理——默认按 DOM 顺序,⚠️ 所以「用 CSSorder/row-reverse重排会让键盘顺序与视觉顺序不一致」,正确做法是改 DOM 顺序。 -
「模态弹窗的无障碍要点有哪些?」
六个:① ⭐ 焦点陷阱——打开时把焦点移进去(优先「业务指定」→ 第一个可聚焦元素),Tab 在首尾循环,且焦点意外逃出时拉回来;② ⭐ 背景隔离——用inert把「弹窗之外的整棵应用」设为不可聚焦 + 不可点击 + 从无障碍树移除(比aria-hidden+pointer-events: none更彻底,避免「Tab 跑到背景」);③ 语义——role="dialog"+ ⭐aria-modal="true"+aria-labelledby指向标题 +aria-describedby指向描述(⭐ 用引用而不是把字符串写进aria-label——单一事实源);④ 关闭后焦点还原——记住打开前的activeElement,关闭时focus()回去(否则用户「不知道自己在哪」);⑤ ESC 关闭(嵌套时只关最上层);⑥ 滚动锁用计数器(嵌套弹窗不会提前解锁,见 1.12)。⭐ 更省心的做法是直接用原生<dialog showModal()>(自动焦点陷阱 +::backdrop+ ESC)。 -
「动态内容怎么让屏幕阅读器知道?」
用aria-live区域:①role="status"(=aria-live="polite"+aria-atomic="true")——⭐ 等用户空闲时播报,适合「加载完成」「已保存」;②role="alert"(=assertive)——⭐ 立刻打断播报,只用于「错误/紧急」,⚠️ 滥用会让用户「一直被打断」;③aria-atomic="true"表示「播报整个区域」而不是只播报变化的那部分。⚠️ 两个关键细节:容器必须一开始就在 DOM 里(「新插入一个带aria-live的元素」通常不会被播报);要「先清空再写入」(内容没变时不会重新播报)。实践做法是「在应用入口创建一个全局sr-only的播报容器 + 一个announce(message)函数」。 -
「无障碍怎么测?」
四层,只有第一层能自动化:① ⭐ 自动工具(覆盖约 30~50%)——axe-core(jest-axe / @axe-core/playwright)、Lighthouse、Pa11y,能查「缺 label、对比度、role 错误、重复 id、缺 lang」,应集成进 CI,但绝不能只靠它;② ⭐ 键盘走查(成本最低、收益最高)——只用 Tab/Shift+Tab/Enter/Space/Esc/方向键走完「主要流程」(能否到达、焦点是否可见、顺序是否合理、能否完成提交/关闭/拖拽);③ 屏幕阅读器实测——VoiceOver(macOS ⌘F5)/ NVDA(Windows 免费),⭐ 重点听「按钮/链接是否被正确播报、表单错误是否被播报、动态内容是否被播报」;④ ⭐ 真实用户测试——只有「用辅助技术的真实用户」能发现「能操作但很难用」的问题。工程化:把「键盘走查」写进 DoD + ESLint 的jsx-a11y规则 + CI 跑 axe。
一句话速记
无障碍的第一原则是「能用原生 HTML 就用原生 HTML」(语义/键盘/role/语音控制/表单语义/默认样式全都白送,用
div + onclick要自己补全全部)——这就是 ARIA 第一规则;ARIA 五规则:能原生就不用 ARIA、不改原生语义、ARIA 控件必须键盘可用、别对可见可访问元素用aria-hidden(用inert)、必须有可访问名字(aria-labelledby>aria-label> 内容 >label>title);键盘四条硬要求:Tab 能到达一切(tabindex > 0几乎总是错的)、焦点必须可见(:focus-visible,绝不能outline: none无替代)、键盘能完成一切(含拖拽)、焦点顺序合理(DOM 顺序 = 视觉顺序,别用 CSSorder重排);模态六要点:焦点陷阱 + ⭐inert背景隔离 +role="dialog"/aria-modal/aria-labelledby+ 关闭还焦点 + ESC + 滚动锁计数器(或直接用原生<dialog>);动态内容用aria-live(role="status"宽松 /role="alert"打断,⚠️ 容器必须一开始就在 DOM 里 + 先清空再写入);测试四层:axe(30~50%,进 CI)→ 键盘走查(最划算) → 屏幕阅读器 → 真实用户;视觉无障碍:对比度 4.5:1/3:1、不靠颜色传达信息、尊重prefers-reduced-motion/prefers-contrast/forced-colors。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!