HTML 表单无障碍实践:label、fieldset 与 aria 的正确组合
HTML表单无障碍需正确绑定label与控件、用fieldset和legend分组选项、以aria-describedby关联提示与错误、校验后同步aria-invalid、区分required与aria-required,并只在无可见文字时使用aria-label,以使屏幕阅读器、键盘和语音控制用户都能顺利填写。
学会这篇,你能把一张「看起来能用」的表单改成屏幕阅读器、键盘、语音控制三类用户都能顺利填完的无障碍表单。
表单无障碍的坑,九成出在「标签挂错」和「分组缺失」上。下面按从易到难的顺序走一遍,每步都给出可直接复制的写法。
第一步:先把 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)也是无障碍的一部分,tel、name、postal-code这些值该填就填,认知障碍用户很依赖它。
小结
- 每个控件都要有真实可读的名称:
label for/id、包裹式 label,或视觉隐藏的 label;placeholder不算。 - 单选组、复选组、地址类字段组,一律用
fieldset+legend打包,legend放第一位。 - 说明文字用
aria-describedby关联,错误信息同步aria-invalid,错误播报要避免多条同时打断。 - 原生语义优先,
aria-*是补丁不是替代品;aria-label只用于无可见文字的场景。 - 交付前用键盘、读屏、自动化工具各走一遍,三关都过才算完成。
转载请注明出处,版权归原作者所有。
正式会员
认证极客






