表单可访问性:使用 aria-hidden 还是从 DOM 移除?
结论
当前不适用的表单字段可以从 DOM 中移除,这不会造成可访问性问题。但对大多数动态表单来说,更方便的做法是:
- 用原生
hidden属性隐藏整个条件区域; - 同时禁用区域内的表单控件;
- 条件重新满足后,再取消隐藏和禁用。
不要只设置 aria-hidden="true"。这个属性只会在辅助技术的可访问性树中隐藏内容,无法阻止键盘聚焦,也不会阻止表单校验和数据提交。如果区域中还有可以聚焦的输入框,就可能出现键盘能够进入,但屏幕阅读器无法识别的严重问题。
推荐的实现方式
先用原生 HTML 表示用户组选择:
<fieldset>
<legend>Select your user group</legend>
<label>
<input type="radio" name="userGroup" value="single" checked>
Single
</label>
<label>
<input type="radio" name="userGroup" value="families">
Families
</label>
</fieldset>
把仅适用于 Families 的字段放在单独的区域中:
<fieldset id="family-fields" hidden disabled>
<legend>Family plan options</legend>
<fieldset>
<legend>Bill payment</legend>
<label>
<input type="radio" name="billPayment" value="combined">
Combined
</label>
<label>
<input type="radio" name="billPayment" value="separate">
Separate
</label>
</fieldset>
<fieldset>
<legend>Data plan usage</legend>
<label>
<input type="radio" name="dataUsage" value="limited">
Limited usage
</label>
<label>
<input type="radio" name="dataUsage" value="shared">
Shared usage
</label>
</fieldset>
</fieldset>
然后根据用户的选择同步更新状态:
const userGroupInputs = document.querySelectorAll(
'input[name="userGroup"]'
);
const familyFields = document.querySelector('#family-fields');
function updateFamilyFields() {
const selectedGroup = document.querySelector(
'input[name="userGroup"]:checked'
)?.value;
const showFamilyFields = selectedGroup === 'families';
familyFields.hidden = !showFamilyFields;
familyFields.disabled = !showFamilyFields;
}
userGroupInputs.forEach((input) => {
input.addEventListener('change', updateFamilyFields);
});
updateFamilyFields();
这两个属性各有作用:
hidden隐藏整个区域,并使其通常不会出现在可访问性树中;disabled阻止内部控件获得焦点、参与约束校验或随表单提交。
仅仅隐藏控件,不一定能让它退出表单校验。因此,不适用的字段还需要禁用,尤其是其中包含 required 控件时。
是否可以从 DOM 中移除
可以。辅助技术只能访问当前存在于页面和可访问性树中的内容,所以移除不适用的字段本身不会引发可访问性问题。
例如,可以用 <template> 保存条件字段:
<div id="conditional-fields"></div>
<template id="family-fields-template">
<fieldset>
<legend>Family plan options</legend>
<label>
<input type="checkbox" name="combinedBilling">
Use combined bill payment
</label>
<label>
<input type="checkbox" name="limitedUsage">
Apply limited data usage
</label>
</fieldset>
</template>
const container = document.querySelector('#conditional-fields');
const template = document.querySelector('#family-fields-template');
function renderConditionalFields(userGroup) {
container.replaceChildren();
if (userGroup === 'families') {
container.append(template.content.cloneNode(true));
}
}
这种方法适合字段结构差异较大的页面,也适合原本就使用组件框架进行条件渲染的项目。不过需要处理几个问题:
- 节点移除后,用户已经填写的值通常会丢失;
- 重新创建节点时,需要重新建立事件处理、错误提示等关联;
- 不要直接移除当前持有焦点的元素。必要时,应先把焦点移到触发切换的控件或其他合适的位置;
- 如果用户可能频繁切换选项,并希望保留已经填写的内容,隐藏并禁用会更合适。
因此,从 DOM 中移除字段在可访问性上没有问题,但维护成本不一定最低。
为什么不建议单独使用 aria-hidden
下面的写法有风险:
<div aria-hidden="true">
<label>
Bill payment
<input type="text" name="billPayment">
</label>
</div>
输入框仍可能获得键盘焦点,也可能参与校验和提交,但屏幕阅读器无法正常识别它。不要在包含可聚焦控件的区域上单独使用 aria-hidden="true"。
如果已经采用以下任意一种方式,通常不需要再添加 aria-hidden:
<section hidden>...</section>
.conditional-section {
display: none;
}
element.remove();
这些方式本身就会让内容不再正常呈现给辅助技术。额外维护 aria-hidden,还可能造成状态不同步。
切换时还要注意什么
使用正确的表单语义
一组选项应使用同名的原生 radio,并通过 <fieldset> 和 <legend> 提供组名。不要用普通文本和可点击的 <div> 模拟单选按钮。
不要随意移动焦点
用户选择 Families 后,通常不用自动把焦点移到新增字段。焦点可以留在刚刚操作的 radio 上,让用户按照正常顺序继续浏览。
只有切换操作会移除当前焦点所在的区域时,才需要主动恢复焦点:
if (familyFields.contains(document.activeElement)) {
document.querySelector(
'input[name="userGroup"]:checked'
)?.focus();
}
保持验证状态一致
隐藏区域中的字段不应继续触发 required 校验。使用 disabled 即可避免这个问题:
familyFields.disabled = familyFields.hidden;
重新启用字段时,还要确认此前留下的错误消息是否仍然适用。
动态内容不一定需要播报
如果新增字段紧跟在选择控件之后,用户通常可以按正常阅读顺序找到它们,不必强制使用 aria-live。只有页面其他位置发生了重要且不易察觉的变化时,才需要考虑添加简短的状态提示。过多的播报会干扰操作。
建议选择
这个表单优先采用“保留 DOM,使用 hidden 加 disabled 切换整个 <fieldset>”的方案。它的语义清楚,状态容易管理,也能避免隐藏字段继续获得焦点、参与校验或随表单提交。
如果项目使用 React、Vue 等框架,并且通常通过条件渲染控制字段,也可以直接从 DOM 中移除它们。处理好焦点、已输入的数据和错误状态即可。aria-hidden 不适合用作动态表单字段的主要隐藏机制。
备注:内容仅供参考。
