HTML 表单无障碍实践:label、fieldset 与 aria 的正确组合
学会这篇,你能把一张「看起来能用」的表单改成屏幕阅读器、键盘、语音控制三类用户都能顺利填完的无障碍表单。
表单无障碍的坑,九成出在「标签挂错」和「分组缺失」上。下面按从易到难的顺序走一遍,每步都给出可直接复制的写法。
第一步:先把 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"` 是对的,但按钮本身不能藏。
第六步:自己动手验一遍
- 只用 Tab 键把整张表单调完,确认焦点顺序符合视觉顺序,且焦点框清晰可见(别用 `outline:none` 干掉)。
- 打开 Chrome DevTools 的 Elements 面板,选中控件,看右侧 Accessibility 树里的 Computed Name——空的就是标签没挂上。
- 用 NVDA(Windows)或 VoiceOver(Mac,Cmd+F5)实际听一遍,重点听分组名和错误提示有没有念出来。
- 装 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` 只用于无可见文字的场景。
- 交付前用键盘、读屏、自动化工具各走一遍,三关都过才算完成。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





