如何正确实现表单关联自定义元素(FACE)的验证与焦点管理

1次阅读

如何正确实现表单关联自定义元素(FACE)的验证与焦点管理

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

text=ZqhQzanResources