
本文详解如何为表单关联自定义元素(Form-Associated Custom Element, FACE)实现符合标准的约束验证(Constraint Validation API),重点解决“An invalid form control is not focusable”报错,确保 checkValidity()、reportValidity() 和表单提交时行为一致、可聚焦、可提示。
本文详解如何为表单关联自定义元素(form-associated custom element, face)实现符合标准的约束验证(constraint validation api),重点解决“an invalid form control is not focusable”报错,确保 `checkvalidity()`、`reportvalidity()` 和表单提交时行为一致、可聚焦、可提示。
在构建表单关联自定义元素(FACE)时,仅设置 static formAssociated = true 和调用 this.attachInternals() 是不够的。浏览器在执行表单验证(如 form.reportValidity() 或原生提交)时,会尝试将焦点移至首个无效控件——若该控件无法被聚焦(例如未显式指定验证锚点),就会抛出经典错误:
An invalid form control with name='foobar' is not focusable.
根本原因在于:浏览器需要明确知道「当验证失败时,应将焦点移至哪个内部 DOM 元素」。而 internals.setValidity() 的第三个参数(anchor)正是为此设计的验证锚点(validity anchor),它必须是一个可聚焦的、已挂载的 元素(或其它可聚焦元素),且该元素需满足 delegatesFocus: true 的 shadow root 配置。
以下是经过验证的、生产就绪的修复方案,涵盖核心要点与最佳实践:
✅ 正确设置验证锚点(关键!)
在调用 this.internals.setValidity() 时,必须传入具体的、可聚焦的子 元素作为第三个参数。例如:
// ✅ 正确:指定首个失效的 input 作为 anchor
const firstInvalidInput = this.shadowRoot.querySelector('input:not(:valid)');
if (firstInvalidInput) {
this.internals.setValidity(
{ customError: true },
'数据不合法,请检查姓名长度',
firstInvalidInput // ← 必须是真实存在的、可聚焦的 HTMLElement
);
} else {
this.internals.setValidity({}); // 清除验证状态
}⚠️ 注意:anchor 参数不可为 null、undefined 或未挂载节点;否则 Chrome/Safari 仍会报错。
✅ 完整可运行示例(精简优化版)
// name.js
class MyName extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
const shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
// 基础模板(移除冗余 hidden 类与延迟逻辑,避免验证时机错乱)
shadow.innerHTML = `
<style>
:host { display: block; }
input:invalid { border-color: #d32f2f; outline: 2px solid #f44336; }
.error { color: #d32f2f; font-size: 0.875rem; margin-top: 4px; }
</style>
<div>
<label>姓氏:<input type="text" name="lastname" minlength="2" required /></label>
<label>名字:<input type="text" name="firstname" minlength="2" required /></label>
<p class="error"></p>
</div>
`;
}
connectedCallback() {
// 绑定输入事件并同步验证状态
this.shadowRoot.querySelectorAll('input').forEach(input => {
input.addEventListener('input', () => this.#updateValidity());
input.addEventListener('blur', () => this.#updateValidity());
});
// 初始化值(若存在 value 属性)
if (this.hasAttribute('value')) {
try {
const data = new FormData();
const parsed = JSON.parse(this.getAttribute('value'));
Object.entries(parsed).forEach(([k, v]) => data.set(k, v));
this.value = data;
} catch {}
}
}
#updateValidity() {
const inputs = this.shadowRoot.querySelectorAll('input');
const invalidInput = [...inputs].find(el => !el.checkValidity());
if (invalidInput) {
const msg = invalidInput.validationMessage || '请输入有效内容';
this.internals.setValidity(
{ customError: true },
msg,
invalidInput // ← 验证锚点:必须传入具体 input 元素
);
this.shadowRoot.querySelector('.error').textContent = msg;
} else {
this.internals.setValidity({});
this.shadowRoot.querySelector('.error').textContent = '';
}
}
// ✅ 暴露标准 API(供表单调用)
get validity() { return this.internals.validity; }
get validationMessage() { return this.internals.validationMessage; }
get willValidate() { return this.internals.willValidate; }
checkValidity() { return this.internals.checkValidity(); }
reportValidity() { return this.internals.reportValidity(); }
// ✅ 实现表单集成生命周期
formResetCallback() {
this.shadowRoot.querySelectorAll('input').forEach(i => i.value = '');
this.#updateValidity();
}
formDisabledCallback(disabled) {
this.shadowRoot.querySelectorAll('input').forEach(i => i.disabled = disabled);
}
// ✅ value getter/setter(返回 FormData 更语义化)
get value() {
const fd = new FormData();
this.shadowRoot.querySelectorAll('input').forEach(input => {
if (input.name) fd.set(input.name, input.value);
});
return fd;
}
set value(fd) {
if (!(fd instanceof FormData)) return;
this.shadowRoot.querySelectorAll('input').forEach(input => {
if (input.name && fd.has(input.name)) {
input.value = fd.get(input.name);
}
});
this.internals.setFormValue(fd); // ← 同步表单值
this.#updateValidity();
}
}
customElements.define('my-name', MyName);✅ HTML 使用示例
<form novalidate>
<my-name name="user-name"></my-name>
<button type="submit">提交</button>
</form>
<script>
document.querySelector('form').addEventListener('submit', e => {
e.preventDefault();
const el = document.querySelector('my-name');
if (el.reportValidity()) {
console.log('验证通过,数据:', Object.fromEntries(el.value.entries()));
}
});
</script>? 关键注意事项总结
- anchor 参数不可省略:setValidity(errors, message, anchor) 中 anchor 必须是当前已渲染、可聚焦(tabIndex >= 0 或原生可聚焦)、且属于该 shadow root 的 元素。
- 避免在 connectedCallback 中过早调用 setValidity:确保所有子 已挂载完成(推荐在 input/blur 事件中触发验证)。
- delegatesFocus: true 是前提:shadow root 创建时必须启用,否则内部 input 无法被 focus() 或浏览器自动聚焦。
- 不要覆盖 reportValidity() 方法体:直接代理 this.internals.reportValidity() 即可;自定义逻辑应在 #updateValidity() 中统一处理。
- formResetCallback 和 formDisabledCallback 必须实现:这是 FACE 规范强制要求,否则表单重置/禁用失效。
- 调试技巧:在 DevTools 中检查 el.internals 对象,观察 validity, validationMessage, willValidate 是否实时更新。
遵循以上模式,你的 FACE 将完全融入原生表单生态:支持 :valid/:invalid 伪类、form.checkValidity()、form.reportValidity()、无障碍焦点导航,以及无报错的原生提交流程。验证不再是个黑盒——而是可控、可测、可扩展的标准化能力。










