fieldset disabled 后 legend 开关仍可用:禁用继承与字段采集
把fieldset设成disabled,里面的输入框通常都会禁用,但放在第一个legend里的开关仍然能点。这不是某个浏览器忘了禁用,而是HTML为整组控件预留的明确例外:首个legend元素后代里的控件,不因为这个fieldset的disabled而一起失去操作能力。
这条规则很适合做“启用整组设置”的开关。开关控制fieldset,如果开关自己也被禁用,使用者就再也打不开这一组。先理解例外,再看控件自己的disabled属性、实际禁用状态和表单采集结果,三者就不会混成一件事。
一个自己还能打开的禁用分组
保存为 fieldset-switch.html,在现代浏览器打开。页面没有请求服务器,所有采集结果直接显示在下方。
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>fieldset 首个 legend 例外</title>
<style>
body { max-width: 44rem; margin: 2rem auto; padding: 1rem; font: 16px sans-serif; }
label { display: block; margin: .7rem 0; }
legend label { display: inline; }
pre { white-space: pre-wrap; }
</style>
<form id="profile">
<fieldset id="contact" disabled>
<legend>
<label><input id="enabled" name="contactEnabled" value="yes" type="checkbox">启用联系信息</label>
<button id="legend-action" type="button">说明</button>
</legend>
<label>邮箱 <input id="email" name="email" type="email" value="reader@example.invalid" required></label>
<label>显示名称 <input id="display" name="display" value="读者" required></label>
</fieldset>
<button id="collect" type="button">采集字段并检查约束</button>
</form>
<p id="status" role="status"></p>
<pre id="states"></pre>
<pre id="result"></pre>
<script>
const fieldset = document.querySelector('#contact');
const enabled = document.querySelector('#enabled');
const email = document.querySelector('#email');
const display = document.querySelector('#display');
const status = document.querySelector('#status');
function refresh() {
fieldset.disabled = !enabled.checked;
status.textContent = enabled.checked ? '联系信息已启用' : '联系信息已禁用,legend 开关仍可用';
const states = [enabled, email, display].map(input => ({
id: input.id,
ownDisabledProperty: input.disabled,
actuallyDisabled: input.matches(':disabled')
}));
document.querySelector('#states').textContent = JSON.stringify(states, null, 2);
}
enabled.addEventListener('change', refresh);
document.querySelector('#legend-action').onclick = () => {
status.textContent = '此按钮位于首个 legend 内,未被外层 fieldset 禁用';
};
document.querySelector('#collect').onclick = () => {
const form = document.querySelector('#profile');
const entries = [...new FormData(form).entries()];
document.querySelector('#result').textContent = JSON.stringify({
entries,
constraintsValid: form.checkValidity()
}, null, 2);
};
refresh();
</script>
</html>
初始状态下邮箱和名称不能编辑,首个legend里的复选框与说明按钮仍可用。此时采集字段,普通禁用字段不会进入结果;勾选开关以后,fieldset解除禁用,邮箱和名称恢复采集。
例子使用保留的无效示例域名,不发送邮件,也不依赖任何真实个人资料。输入数据只是为了让提交条目变化容易观察。
继承来的禁用,不会自动把 input.disabled 改成 true
看状态输出,邮箱自己的disabled属性仍为false,但:disabled匹配为true。这是因为禁用状态可以来自祖先fieldset,不只来自控件自己的属性。
用 if (!input.disabled) 推断控件当前可交互,会漏掉祖先禁用的情况。排查浏览器状态时,:disabled更接近原生控件最终参与的禁用规则;但它也不是任意自定义元素的通用业务权限检查,组件仍有自己的状态合同。
解除fieldset以后,某个控件若本来就单独设置了disabled,应该继续禁用。组级开关不应遍历后代把所有disabled属性清空,否则会破坏控件原本的独立限制。让浏览器按祖先规则计算状态,比维护一份“之前哪些按钮被禁过”的列表简单。
first legend的例外也只针对这层fieldset。若legend里的控件自己显式disabled,或还受到其他有效禁用条件影响,它不会因此变成永远可用。规则提供的是一条豁免,不是最高优先级的强制启用。
第一个 legend 应该承担组名,不是随便找一个位置
fieldset的legend给这一组控件提供可理解的标题。将开关放在标题里,要让文字同时说明分组用途与开关含义,不能只放一个没有名字的小方框。
不要为套用例外再往同一个fieldset里添加多份legend。合法结构与可访问命名本身需要保持清楚;规则提到“第一个”,不等于建议靠多个legend组织所有操作区域。
若开关不适合放进组标题,也可以把它放在fieldset外面,让它正常控制组状态。这是另一种清晰结构,不必为追求所有东西视觉上都在同一个边框里而制造嵌套与焦点问题。
键盘验证尤其有用:初始状态用Tab应能到达legend开关,勾选以后才进入后面的编辑字段。仅用鼠标观察灰色样式,很难发现开关实际上已经跟着整组一起被禁用。
禁用也不会清空控件已有值。先修改显示名称,再关闭分组,随后重新启用,用户刚才输入的值仍应保留。若产品要求关闭功能就同时删除配置,应在确认动作中明确执行,而不是依赖disabled顺便清除数据。浏览器的编辑状态与持久化配置是两份不同状态。
fieldset本身不是一条普通输入值,给它写name也不会让整组后代自动打包成一个对象。服务器要什么字段结构,仍由后代控件的name以及提交协议决定;分组语义和数据命名不能互相代替。
字段不提交以后,服务器要怎样解释
禁用字段通常不参与这次FormData条目构造。服务器收到缺少email,不应凭空猜是用户清空邮箱、前端组被禁用,还是请求被篡改。接口需要明确未提供字段的语义。
这个页面有一个contactEnabled字段,但它作为复选框,未勾选时本身也不会产生条目。因此服务端可以按事先约定把缺失解释为关闭,也可以要求前端显式构造布尔值。选择要写进协议,不能靠某次抓包结果临时推断。
readonly与fieldset disabled也不能互换。只想展示不可修改但仍应提交的值,通常应该按控件类型选择相应只读方案;如果只是关闭一整组可选功能,disabled的提交过滤可能正好符合需求。它们改变的是不同数据合同。
约束验证同样受实际禁用状态影响。把邮箱清空,再在启用和禁用之间切换,观察checkValidity:启用时required参与校验,禁用后相应控件不再按同样方式阻止提交。这个变化属于浏览器交互辅助,不会减轻后端对输入和权限的独立核对。
把开关移出legend、仍留在fieldset普通内容区,再给fieldset初始disabled,开关就会随整组禁用,无法自行恢复。将它移回首个legend,或直接移到fieldset外面,才能保持这条启用入口可操作。