HTML 表单无障碍实践:label、fieldset 与 aria 的正确组合

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-19 23:41 ·4 浏览 ·0 回复

学会这篇,你能把一张「看起来能用」的表单改成屏幕阅读器、键盘、语音控制三类用户都能顺利填完的无障碍表单。

表单无障碍的坑,九成出在「标签挂错」和「分组缺失」上。下面按从易到难的顺序走一遍,每步都给出可直接复制的写法。

第一步:先把 label 和控件真正绑上

最基础的写法是 `for` 与 `id` 配对:

<div class="field">
  <label for="email">邮箱地址</label>
  <input type="email" id="email" name="email" autocomplete="email">
</div>

也可以用包裹式,省掉 id:

<label>
  <input type="checkbox" name="agree"> 我已阅读并同意用户协议
</label>

如果设计稿上不能出现文字(比如搜索框只留图标),用视觉隐藏而不是删掉标签:

.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;}
<label for="q" class="sr-only">搜索关键词</label>
<input id="q" type="search" placeholder="搜索…">

注意:`placeholder` 不能替代 label。它一来对比度常常不达标,二来用户一输入就消失,三来部分屏幕阅读器读法不稳定。`title` 同理,只能算补充。

第二步:成组的选项必须用 fieldset + legend

单选组(radio)和复选组(checkbox)是重灾区。视觉上它们是一组,代码里却常常只是几个平行的 input,屏幕阅读器会逐个念「顺丰 单选按钮」,用户根本不知道这组在问什么。

<fieldset>
  <legend>收货方式</legend>
  <label><input type="radio" name="ship" value="fast"> 顺丰次日达</label>
  <label><input type="radio" name="ship" value="normal"> 普通快递</label>
</fieldset>

这样念出来是「收货方式,顺丰次日达,单选按钮,二选一」。

注意:`legend` 必须是 `fieldset` 的第一个子元素,否则分组名称可能不生效。另外别为了省事把 fieldset 的默认边框全清掉后忘了它还在——布局异常时先查这里。

如果因为 CSS 框架限制实在用不了 fieldset,退而求其次:

<div role="group" aria-labelledby="ship-title">
  <span id="ship-title">收货方式</span>
  ...
</div>

第三步:提示文字和错误信息用 aria-describedby 挂上去

输入框旁边的「至少 8 位」「格式如 138xxxx」这类说明,视觉用户看得到,读屏用户得靠 `aria-describedby` 关联:

<label for="pwd">密码</label>
<input id="pwd" type="password" aria-describedby="pwd-hint">
<p id="pwd-hint">至少 8 位,需含字母与数字</p>

校验失败时,把错误节点也加进去,并同步 `aria-invalid`:

<input id="pwd" type="password" aria-describedby="pwd-hint pwd-err" aria-invalid="true">
<p id="pwd-err" role="alert">密码长度不足 8 位</p>

注意:`aria-invalid` 要在提交校验后才置为 `true`,别在初始 HTML 里就写死。`role="alert"` 会立即播报,如果一次提交冒出五条错误,用户会被连续打断——这种情况改用页面顶部的错误汇总区,`role="alert"` 加在汇总容器上,并让焦点跳到第一个出错的字段。

第四步:分清 required 和 aria-required

原生 `required` 已经隐含了「必填」语义,浏览器还会做原生校验拦截,一般不需要再加 `aria-required="true"`。只有当你用 JS 完全接管校验、又不想触发原生气泡时,才用 `aria-required` 代替。

同理,能用原生元素就别造 role:`<button>` 不要写成 `<div role="button">`,因为 div 不会自动获得键盘响应和焦点。

第五步:aria-label 只用于没有可见文字的地方

图标按钮、关闭按钮这类没有文字的场景,用 `aria-label`:

<button type="button" aria-label="关闭弹窗">×</button>

有可见文字时优先用 `aria-labelledby` 指向那个文字节点,而不是再用 `aria-label` 重复一遍。原因是语音控制用户会说「点击 提交」——如果可见文字是「提交」,而 `aria-label` 写成了「提交表单」,这个按钮就点不动了(对应 WCAG 2.5.3 名称与可见标签一致)。

注意:`aria-hidden="true"` 千万不要加在可聚焦元素或其父元素上,会出现「焦点跑进一个读不出来的黑洞」。装饰性图标加 `aria-hidden="true"` 是对的,但按钮本身不能藏。

第六步:自己动手验一遍

  1. 只用 Tab 键把整张表单调完,确认焦点顺序符合视觉顺序,且焦点框清晰可见(别用 `outline:none` 干掉)。
  2. 打开 Chrome DevTools 的 Elements 面板,选中控件,看右侧 Accessibility 树里的 Computed Name——空的就是标签没挂上。
  3. 用 NVDA(Windows)或 VoiceOver(Mac,Cmd+F5)实际听一遍,重点听分组名和错误提示有没有念出来。
  4. 装 axe DevTools 或跑一次 Lighthouse,能扫出大部分低级问题。

注意:浏览器自动补全(`autocomplete`)也是无障碍的一部分,`email`、`tel`、`name`、`postal-code` 这些值该填就填,认知障碍用户很依赖它。

小结

  • 每个控件都要有真实可读的名称:`label for/id`、包裹式 label,或视觉隐藏的 label;`placeholder` 不算。
  • 单选组、复选组、地址类字段组,一律用 `fieldset` + `legend` 打包,`legend` 放第一位。
  • 说明文字用 `aria-describedby` 关联,错误信息同步 `aria-invalid`,错误播报要避免多条同时打断。
  • 原生语义优先,`aria-*` 是补丁不是替代品;`aria-label` 只用于无可见文字的场景。
  • 交付前用键盘、读屏、自动化工具各走一遍,三关都过才算完成。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-529.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~