React 无障碍设计
每个 React 应用都渲染成浏览器一直以来就有的 HTML,所有辅助工具都是通过你的组件产生的 DOM 来工作的。React 中的无障碍设计主要就是对这个 DOM 做的一系列小的选择:你渲染哪个元素、它怎样获得名称,以及当屏幕上的内容改变时,看不见这个改变的人会发生什么。
讲别的之前先说一个 JSX 的细节。React 把 class 改成了 className,把 for 改成了 htmlFor,但 ARIA 属性保留连字符:aria-live、aria-label,还有普通的 role。
语义化元素是基础
<button> 自带一大堆行为。它在制表符顺序里,所以键盘能到达它。它在 Enter 和 Space 时触发点击处理程序。屏幕阅读器把它读作按钮,读出它的文本作为名称,这也是语音控制软件的目标名称。浏览器处理禁用状态、焦点环和激活样式。
// 浏览器给你焦点、键盘激活和"按钮"提示
<button className="die" onClick={hold}>{value}</button>一个带 onClick 处理程序的 <div> 只能从那个列表中得到一项:点击。Tab 会跳过它,Enter 和 Space 做不了什么,屏幕阅读器把它读作一段文本,完全没有提示任何互动会发生什么。
常见的补救方法是加上 role="button" 和 tabIndex={0},这把元素放进制表符顺序,改变了阅读的内容。但行为还是缺少的。你需要加一个 onKeyDown 处理程序,检查 Enter 和 Space,在 Space 上调用 preventDefault() 让页面停止滚动,然后手动维护一个和样式同步的禁用状态。这是一大堆代码,只是在重建浏览器本来就有的东西。用真正的 <button> 是更短的路,而且当浏览器改变时也能保持正确。
同样的逻辑贯穿整个标记:用 <a href> 导航,用 <nav> 和 <main> 作为屏幕阅读器能在其间跳转的地标,按顺序用标题构建大纲供人导航。React 代码库里大部分无障碍设计工作就是选择已经能做这项工作的元素。
通知内容改变
单页应用原地更新。没有页面加载来告诉屏幕阅读器发生了什么,所以屏幕中间渲染的改变可能完全无声。Live region(活跃区域)把这个信息交给屏幕阅读器:一个容器,屏幕阅读器会监视它,每当内容改变时就通知用户。下面的 sr-only 类隐藏了它的视觉外观,使用的是本章后面介绍的 CSS 模式。
<div aria-live="polite" className="sr-only">
{isGameWon && <p>你赢了!按新游戏重新开始。</p>}
</div>这个包装容器每次都渲染,一开始是空的,当 isGameWon 改变时 React 把一个段落交换进去。这个顺序是大家容易搞错的地方。携带 aria-live 的元素必须在内容到达之前就在 DOM 里,因为屏幕阅读器在遇到活跃区域时就注册它们,然后监视变化。如果你在一次渲染里同时挂载区域和它的文本,很多屏幕阅读器什么都不会通知:整个东西看起来像普通的新内容。在树里保留一个空区域成本为零,却能让通知可靠。
aria-live="polite" 把通知放进一个队列。屏幕阅读器会先读完它正在读的,然后在下一个自然停顿处传递你的消息,这可能是在视觉改变后的一刻。这个延迟是故意的,polite 对几乎所有情况都是正确的设置。
键盘交互
Tab 在可获焦点元素间向前移动,Shift+Tab 向后移动,Enter 激活链接和按钮,Space 激活按钮和切换复选框。
Tab 顺序遵循 DOM 顺序,所以你的 JSX 渲染的顺序就是人们通过的顺序。用 CSS 在视觉上重新排序会留下一个在屏幕上跳跳蹦蹦的 Tab 顺序,正的 tabIndex 值也会故意造成同样的混乱。tabIndex={-1} 是有用的那个:它让一个元素能从 JavaScript 获得焦点,同时保持它在 Tab 序列之外,这正是像对话框标题这样的焦点目标需要的。
还有两条规则。保持焦点可见:除非用自己的 :focus-visible 样式替换,否则避免 outline: none。保持一个退出路线:一个故意在内部锁定焦点的模态框需要 Escape 来关闭,并需要把焦点还给触发它的元素。
有意移动焦点
当 UI 改变形状时,焦点可能最后没地方了。有人激活一个按钮,按钮被移除或替换,焦点回退到 <body>。下一次 Tab 从页面顶部开始,读者失去了他们的位置。
修复办法是把焦点移到某个合理的地方,这是 ref 的合法用途之一:
function NewGameButton({ isGameWon, onNewGame }) {
const buttonRef = useRef(null)
useEffect(() => {
if (isGameWon) {
buttonRef.current.focus()
}
}, [isGameWon])
return <button ref={buttonRef} onClick={onNewGame}>新游戏</button>
}effect 在 React 把那个节点提交到屏幕后运行,所以元素已经在那里可以接收焦点。在 isGameWon 上做守卫让它不会在每次渲染时都抢焦点。
同样的模式涵盖了其他常见时刻:对话框在打开时接收焦点,关闭时把它还给触发器,验证失败时把焦点送到第一个无效字段,删除一行时把焦点移到替代它的那一行。下面的规则只有一行:如果你的代码移除了拥有焦点的东西,你的代码就要决定焦点接下来去哪。
视觉隐藏文本
很多状态从布局上是显而易见的,但屏幕阅读器听不到:字段旁的绿色勾号、看起来被按下的骰子、清晰可见的数字。视觉隐藏文本把这个信息告诉听页面的人。
约定是用一个叫 sr-only 的类。它对 React 或浏览器没有意义:就是一个普通的类名,这些 CSS 规则才是做实际工作的。
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}元素留在无障碍树里,但在视觉上不占空间。display: none 和 visibility: hidden 会把它也从树里剥离,对所有人都隐藏了。
只有图标的按钮是日常情况。要么给它一个 aria-label,要么把真实的文本放在里面并在视觉上隐藏它:
<button onClick={onClose}>
<XIcon aria-hidden="true" />
<span className="sr-only">关闭</span>
</button>aria-hidden="true" 让装饰性的 SVG 不被通知,隐藏的 span 提供了名称。aria-label 有一个注意事项:它在交互元素和任何带显式 role 的东西上设置无障碍名称,但浏览器经常在没有 role 的普通 <div> 或 <span> 上忽略它。把它限制在按钮、链接、输入和标记的地标上。
只阅读源代码远远不够了解这一切。用 Cmd+F5 打开 VoiceOver 听听你自己的应用,在浏览器里运行 axe DevTools 来自动捕获缺失的标签和未命名的控件。
button,每个输入旁放一个真正的 label。这些元素自带键盘支持和屏幕阅读器能读的名称,全部免费。当屏幕上的东西改变时,听页面的人会不然会错过,把一个短句放在带 aria-live="polite" 的 div 里,从一开始就把那个 div 保留在页面上,这样改变就会被注意到。 下一步:超越基础,一份基础之后是什么的地图。

