如何正确实现表单关联自定义元素(FACE)的验证逻辑

2次阅读

如何正确实现表单关联自定义元素(FACE)的验证逻辑

本文详解如何为表单关联自定义元素(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 是必需的:它们确保自定义元素能响应
    和 form.disabled = true 等原生行为。
  • 不要覆盖 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” 报错。

text=ZqhQzanResources