
本文详解如何为表单关联自定义元素(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()、无障碍焦点导航,以及无报错的原生提交流程。验证不再是个黑盒——而是可控、可测、可扩展的标准化能力。