
本文详解如何为表单关联自定义元素(Form-Associated Custom Element, FACE)实现符合标准的约束验证(Constraint Validation API),重点解决“invalid form control is not focusable”报错,确保 checkValidity()、reportValidity() 和表单提交流程正常工作。
本文详解如何为表单关联自定义元素(form-associated custom element, face)实现符合标准的约束验证(constraint validation api),重点解决“invalid form control is not focusable”报错,确保 `checkvalidity()`、`reportvalidity()` 和表单提交流程正常工作。
在构建可参与原生表单验证的自定义元素(即 FACE)时,仅设置 Static formAssociated = true 和调用 this.attachInternals() 是远远不够的。一个常见且令人困惑的错误是:当表单尝试提交或调用 form.reportValidity() 时,控制台抛出 “An invalid form control with name=’xxx’ is not focusable” —— 这并非表示你的元素无效,而是浏览器无法定位到一个可聚焦的、承载验证状态的内部控件。
根本原因在于:internals.setValidity() 必须显式传入一个可聚焦的 dom 元素作为第三个参数(即 validity anchor),否则浏览器在触发错误提示时不知道该将焦点移向何处,从而中断验证流程。
以下是一个修复后的、生产就绪的 MyName 自定义元素实现,已通过 chrome/firefox/safari 验证:
// name.js class MyName extends HTMLElement { static formAssociated = true; constructor() { super(); this.internals = this.attachInternals(); const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true }); // 初始化 FormData(用于提交值) this._data = new FormData(); this._data.set('firstname', ''); this._data.set('lastname', ''); // 渲染 Shadow DOM shadowRoot.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> <h2 part="title">Name Form</h2> <p><input type="text" name="firstname" placeholder="First name" minlength="2" required /></p><div class="aritcle_card flexRow"> <div class="artcardd flexRow"> <a class="aritcle_card_img" href="/ai/927" title="Calliper 文档对比神器"><img src="https://img.php.cn/upload/ai_manual/000/000/000/175679997868619.jpg" alt="Calliper 文档对比神器" onerror="this.onerror='';this.src='/static/lhimages/moren/morentu.png'" ></a> <div class="aritcle_card_info flexColumn"> <a href="/ai/927" title="Calliper 文档对比神器">Calliper 文档对比神器</a> <p>文档内容对比神器</p> </div> <a href="/ai/927" title="Calliper 文档对比神器" class="aritcle_card_btn flexRow flexcenter"><b></b><span>下载</span> </a> </div> </div> <p><input type="text" name="lastname" placeholder="Last name" minlength="2" required /></p> <p class="error"></p> </div> `; } connectedCallback() { if (this._initialized) return; this._initialized = true; const inputs = this.shadowRoot.querySelectorAll('input'); const errorEl = this.shadowRoot.querySelector('.error'); // 绑定输入事件,实时更新数据与验证状态 inputs.forEach(input => { input.addEventListener('input', () => { this._data.set(input.name, input.value); this.internals.setFormValue(this._data); this._updateValidity(); }); // 同步初始值(如通过 value 属性设置) if (this.hasAttribute('value')) { try { const parsed = JSON.parse(this.getAttribute('value')); if (parsed.firstname && parsed.lastname) { input.value = parsed[input.name] || ''; this._data.set(input.name, input.value); } } catch (e) { /* ignore */ } } }); // 初始化验证状态 this._updateValidity(); } // ✅ 关键修复:setValidity 必须传入具体的 input 元素作为 anchor _updateValidity() { const inputs = this.shadowRoot.querySelectorAll('input'); let isValid = true; let firstInvalidInput = NULL; for (const input of inputs) { if (!input.checkValidity()) { isValid = false; firstInvalidInput = input; break; } } if (isValid) { this.internals.setValidity({}); } else { // ⚠️ 第三个参数必须是可聚焦的 <input> 元素! this.internals.setValidity( { customError: true }, 'Please fill in both names correctly.', firstInvalidInput // ← validity anchor ); } } // ✅ 暴露标准属性与方法(供表单和开发者调用) get validity() { return this.internals.validity; } get willValidate() { return this.internals.willValidate; } checkValidity() { return this.internals.checkValidity(); } reportValidity() { return this.internals.reportValidity(); } // ✅ 值访问器(支持双向绑定) get value() { return this._data; } set value(v) { if (v instanceof FormData) { this._data = v; this.shadowRoot.querySelector('input[name="firstname"]').value = v.get('firstname') || ''; this.shadowRoot.querySelector('input[name="lastname"]').value = v.get('lastname') || ''; this.internals.setFormValue(v); this._updateValidity(); } } // ✅ 表单生命周期回调 formResetCallback() { this._data.set('firstname', ''); this._data.set('lastname', ''); this.shadowRoot.querySelector('input[name="firstname"]').value = ''; this.shadowRoot.querySelector('input[name="lastname"]').value = ''; this.internals.setFormValue(this._data); this._updateValidity(); } formDisabledCallback(isDisabled) { this.shadowRoot.querySelectorAll('input').forEach(i => i.disabled = isDisabled); } } customElements.define('my-name', MyName);
使用示例(HTML + 表单集成)
<form id="myForm"> <my-name name="person"></my-name> <button type="submit">Submit</button> </form> <pre class="brush:php;toolbar:false;" id="formdata">
<script> document.getElementById(‘myForm’).addEventListener(‘submit’, e => { e.preventDefault(); const el = document.querySelector(‘my-name’); if (el.checkValidity()) { console.log(‘✅ Valid!’); const data = Object.fromEntries(el.value.entries()); document.getElementById(‘formdata’).textContent = JSON.stringify(data, null, 2); } else { console.log(‘❌ Invalid — browser will show native popover’); el.reportValidity(); // 触发聚焦与提示 } }); </script>
关键注意事项与最佳实践
- setValidity(anchor) 的 anchor 参数不可省略:它必须是 Shadow DOM 中一个真实的、可聚焦的 元素(不能是 this 或 shadowRoot)。这是解决 “not focusable” 错误的唯一可靠方式。
- 避免重复调用 setFormValue() 在非必要时机:每次调用都会触发表单状态变更,建议只在值真正变化后调用。
- formResetCallback 和 formDisabledCallback 是必需的:它们确保自定义元素能响应
- 不要覆盖 reportValidity() 方法体:你当前代码中重写了 reportValidity() 却引用了未定义的 _internals,应直接委托给 this.internals.reportValidity()。
- 样式兼容性:使用 :host 和 input:invalid 可以无缝继承原生验证样式;若需自定义错误提示,建议结合 validity.customError + setCustomValidity(”) 清除状态。
? 提示:Material Web 的 constraint-validation.ts 是极佳参考——它将验证逻辑抽象为 mixin,并强制子类实现 getValidityAnchor(): Element | null,确保每个 FACE 都明确声明其 validity anchor。
通过以上实现,你的自定义元素将完全融入 HTML 表单生态:支持 required、minlength、pattern 等原生约束,响应 checkValidity()、reportValidity()、form.reportValidity(),并在提交失败时正确聚焦并显示原生错误气泡,彻底告别 “not focusable” 报错。