Skip to content
This page has been auto-translated and may contain errors.View in English

无障碍 React

每个 React 应用都呈现为浏览器一直以来交付的相同 HTML,每个辅助工具都从你的组件生成的 DOM 中工作。React 中的无障碍设计主要是关于那个 DOM 的一系列小选择:你呈现什么元素、它如何获得名称,以及当屏幕变化而某人看不见时你做什么。

先说一个 JSX 细节。React 把 class 改成 className,把 for 改成 htmlFor,但 ARIA 属性保留连字符:aria-livearia-label,还有普通的 role

语义化元素优先

一个 <button> 自带一堆行为。它在 Tab 顺序中,所以键盘能到达它。它在 Enter 和 Space 上触发点击处理器。屏幕阅读器把它读作一个按钮,读出它的文本作为名称,这也是语音控制软件的目标名称。浏览器处理禁用状态、焦点环和激活样式。

jsx
// 浏览器给你焦点、键盘激活和"按钮"公告
<button className="die" onClick={hold}>{value}</button>

一个带 onClick 处理器的 <div> 只能从那个列表中得到一项:点击。Tab 跳过它,Enter 和 Space 什么都不做,屏幕阅读器把它读作一段文本,没有暗示有任何交互会发生什么。

通常的补丁是加上 role="button"tabIndex={0},这把元素放进 Tab 顺序并改变公告内容。但行为仍然缺失。你还要添加一个 onKeyDown 处理器,检查 Enter 和 Space,在 Space 上调用 preventDefault() 以防页面停止滚动,然后保持手写的禁用状态与样式同步。这是相当多的代码,只是为了重建浏览器已经提供的东西。直接用真实的 <button> 是更短的路,而且当浏览器改变时它保持正确。

同样的逻辑贯穿其余标记:<a href> 用于导航,<nav><main> 作为屏幕阅读器可以在其间跳转的地标,有序的标题作为人们导航的大纲。React 代码库中大部分无障碍工作是选择已经做这个工作的元素。

公告什么变化了

单页应用原地更新。没有页面加载告诉屏幕阅读器发生了什么,所以一个在屏幕中间呈现的变化可能完全无声。一个活跃区域移交那个信息:一个屏幕阅读器观察的容器,每当其内容改变时就公告。下面的 sr-only 类在视觉上隐藏它,使用本章后面介绍的 CSS 模式。

jsx
<div aria-live="polite" className="sr-only">
  {isGameWon && <p>你赢了!按"新游戏"重新开始。</p>}
</div>

包装器每次都呈现,最初是空的,当 isGameWon 翻转时 React 把一个段落换进去。那个顺序是人们常弄错的部分。携带 aria-live 的元素必须在内容到达前就在 DOM 中,因为屏幕阅读器在遇到活跃区域时注册它们,然后监视变化。在单一渲染中挂载区域及其文本,许多屏幕阅读器什么都不公告:整个东西看起来像普通的新内容。在树中保持一个空区域成本为零,使公告可靠。

aria-live="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 的合法用途之一:

jsx
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 规则做的是工作。

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: nonevisibility: hidden 会把它也从那棵树中移除,向所有人隐藏它。

仅图标按钮是日常情况。要么给它一个 aria-label,要么把真实文本放在里面并在视觉上隐藏:

jsx
<button onClick={onClose}>
  <XIcon aria-hidden="true" />
  <span className="sr-only">关闭</span>
</button>

aria-hidden="true" 把装饰性 SVG 排除在公告外,隐藏的 span 提供名称。关于 aria-label 有个警告:它在交互元素和任何携带显式 role 的东西上设置无障碍名称,浏览器经常在普通 <div> 或没有 role 的 <span> 上忽略它。把它限制在按钮、链接、输入和标记地标上。

仅读源代码对这些东西中的任何一个都只能带你走这么远。用 Cmd+F5 打开 VoiceOver 并听你自己的应用,在浏览器中运行 axe DevTools 来自动捕捉缺失的标签和未命名的控件。

每个表单控件都需要一个标签,表单 是差距最显著的地方。一个绑定到输入的 <label> 给字段其无障碍名称,所以屏幕阅读器在焦点落在那里时读"电子邮件地址,编辑文本",标签文本变成字段的点击目标。

两种接线有效。用 React 的 htmlFor 作为 HTML for 属性通过 id 指向输入:

jsx
<label htmlFor="email">电子邮件地址</label>
<input id="email" type="email" name="email" />

或者把输入包装在标签中并完全跳过 id:

jsx
<label>
  电子邮件地址
  <input type="email" name="email" />
</label>

包装适合复选框或单选按钮,那里文本已经坐在控件旁。htmlFor 版本给你更自由的布局控制。

一个硬编码的 id 如 email 对一个页面上的一个表单有效。把那个标记提升到可重用的 <TextField> 中,同一页面上的两个实例发出相同的 id,所以 htmlFor 绑定到先渲染的那个,标签对之后的每个字段都无声地停止工作。useId 为每个组件实例生成一个唯一的 id,这就是 React 添加它的工作:

jsx
function TextField({ label, ...props }) {
  const id = useId()

  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} {...props} />
    </>
  )
}

为相关 id 添加后缀,${id}-hint 作为描述元素,所以一个调用覆盖整个控件。

占位符文本做不同的工作。占位符在某人输入一个字符的瞬间消失,所以当它携带字段唯一的描述时,那个描述在最需要检查答案时消失。默认占位符样式是浅灰色,这通常不满足对比度要求,屏幕阅读器对该属性的支持不一致。用它作为预期格式的例子,在读"电子邮件地址"的标签下用 jane@example.com

额外的帮助文本和错误消息用 aria-describedby 附加,它指向持有文本的元素的 id:

jsx
<label htmlFor="password">密码</label>
<input
  id="password"
  type="password"
  aria-describedby="password-hint"
  aria-invalid={error ? true : undefined}
/>
<p id="password-hint">{error || '至少 12 个字符。'}</p>

描述在标签和字段类型后读,所以它作为上下文而不是名称到达。aria-invalid 把字段标记为验证失败,把错误文本交换到 aria-describedby 已经指向的元素中保持公告在屏幕阅读器追踪的节点上。更高一层,一组单选按钮属于一个 <fieldset> 内,带一个 <legend> 携带问题。

aria-live 取三个值,选择决定区域是帮助还是伤害。off 是默认值,意味着变化无声。polite 把公告排队,当屏幕阅读器到达它已经在说的东西中的停顿时交付。assertive 中断,切断当前公告来交付你的。Assertive 几乎总是错误的选择:为真正阻挡人进展的东西保留它,比如会话在十秒后过期。一个保存确认、一个搜索结果计数、一个游戏状态变化都属于礼貌区域。

两个 role 携带隐含的礼貌性,倾向于比裸 aria-live 属性更一致地宣布:role="status" 表现为礼貌,role="alert" 表现为断言,role="status"aria-live="polite" 是状态区域的坚实默认。aria-atomic="true" 则在任何变化时读区域的整个内容,适合只有整体才有意义的短句;默认只读改变的东西,适合每行独立的日志。

值得命名的失败模式是公告过多的区域。把一个连到在每次按键时更新的值,比如搜索框下的结果计数,每个字符排队另一个公告。礼貌交付添加到队列而不是替换它,所以人听到他们仍在输入的字段上陈旧数字的流,他们自己的输入回声被埋没。闪烁的加载标志或三个竞争区域造成同样的堆积。

所以保持活跃区域少,对任何由输入驱动的东西去抖,直到值稳定,只公告有视力的用户会抬头看的时刻。一个什么都不说的应用至少是可探索的:人可以用屏幕阅读器自己的命令按自己的步速导航。一个不停说话的应用是人们离开的那个。

Juno正确的元素做大部分工作 当某物可点击时到达真实 button,每个输入旁有真实 label。这些元素自带键盘支持和屏幕阅读器能读的名称,全部免费。

当屏幕上有东西改变,一个听页面的人会不然错过,把一个短句放在一个带 aria-live="polite"div 中,从一开始就把那个 div 保持在页面上以便注意到变化。

Juno正确的元素做大部分工作 语义化元素用零代码给你焦点、键盘激活和公告,这就是为什么用 roletabIndex 补丁一个 div 留下你写自己的键处理。

htmlFor 或包装 label 标记每个控件,把占位符视为格式提示,因为它在某人输入时消失。

保持一个 aria-live="polite" 区域挂载并交换其文本,每当你的代码移除有焦点的东西时用 ref 移动焦点。

Juno正确的元素做大部分工作 活跃区域在屏幕阅读器遇到它们时注册,所以区域必须在内容改变前在 DOM 中,polite 在语音的下一个停顿时交付,而 assertive 中断且几乎总是错误的选择。

role="status"role="alert" 携带相同的礼貌性并更一致,aria-atomic 决定整个区域或仅增量被读。

一个过于急切的由按键驱动的区域排队公告速度比它们能被说出的速度更快,这对用户来说比无声更糟。

接下来:超越基础,一个基础之后来什么的地图。