
本文详解如何为表单关联自定义元素(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” 报错。







