ElementInternals 和表单关联自定义元素

Safari Technology Preview 162 中,我们默认启用了 ElementInternals 和表单关联自定义元素的支持。自定义元素是一项允许 Web 开发者通过定义自己的 HTML 元素来创建可重用组件,而无需依赖 JavaScript 框架的功能。ElementInternals 是自定义元素 API 的新成员,它允许开发者管理自定义元素的内部状态,例如默认的 ARIA 角色或 ARIA 标签,并使自定义元素能够参与表单提交和验证。

自定义元素的默认 ARIA

要在自定义元素中使用 ElementInternals,只需在自定义元素构造函数中调用 this.attachInternals(),就像我们调用 attachShadow() 一样:

class SomeButtonElement extends HTMLElement {
    #internals;
    #shadowRoot;
    constructor()
    {
        super();
        this.#internals = this.attachInternals();
        this.#internals.ariaRole = 'button';
        this.#shadowRoot = this.attachShadow({mode: 'closed'});
        this.#shadowRoot.innerHTML = '<slot></slot>';
    }
}
customElements.define('some-button', SomeButtonElement);

这里,#internals#shadowRoot私有成员字段。上面的代码将定义一个简单的自定义元素,其ARIA 角色默认为 button。如果不用 ElementInternals 达到同样的效果,则需要像这样在自定义元素本身上附加 ARIA 内容属性:

class SomeButtonElement extends HTMLElement {
    #shadowRoot;
    constructor()
    {
        super();
        this.#shadowRoot = this.attachShadow({mode: 'closed'});
        this.#shadowRoot.innerHTML = '<slot></slot>';
        this.setAttribute('role', 'button');
    }
}
customElements.define('some-button', SomeButtonElement);

这段代码存在一些问题。首先,一个元素自动在其自身上添加内容属性令人惊讶,因为没有内置元素这样做。但更重要的是,上述代码阻止了该自定义元素的用户像这样覆盖 ARIA 角色,因为构造函数将在升级时覆盖角色内容属性:

<some-button role="switch"></some-button>

如上所示,使用 ElementInternalsariaRole 属性,此示例可以无缝工作。ElementInternals 同样允许指定其他 ARIA 功能的默认值,例如ARIA label

参与表单提交

ElementInternals 还为自定义元素增加了参与表单提交的能力。要使用自定义元素的此功能,我们必须声明自定义元素与表单关联,如下所示:

class SomeButtonElement extends HTMLElement {
    static formAssociated = true;
    static observedAttributes = ['value'];
    #internals;
    constructor()
    {
        super();
        this.#internals = this.attachInternals();
        this.#internals.ariaRole = 'button';
    }
    attributeChangedCallback(name, oldValue, newValue)
    {
        this.#internals.setFormValue(newValue);
    }
}
customElements.define('some-button', SomeButtonElement);

通过上述 some-button 元素的定义,some-button 将提交在该元素上指定的 value 属性的值,对应于在同一元素上指定的 name 属性。例如,如果我们有像 <some-element name="some-key" value="some-value"></some-element> 这样的标记,我们将提交 some-key=``some-value

参与表单验证

同样,ElementInternals 为自定义元素增加了参与表单验证的能力。在以下示例中,some-text-field 被设计为要求其 shadow tree 内部的 input 元素至少有两个字符。当字符少于两个时,它使用 setValidity()reportValidity() 通过浏览器的原生 UI 向用户报告验证错误:

class SomeTextFieldElement extends HTMLElement {
    static formAssociated = true;
    #internals;
    #shadowRoot;
    constructor()
    {
        super();
        this.#internals = this.attachInternals();
        this.#shadowRoot = this.attachShadow({mode: 'closed', delegatesFocus: true});
        this.#shadowRoot.innerHTML = '<input autofocus>';
        const input = this.#shadowRoot.firstChild;
        input.addEventListener('change', () => {
            this.#internals.setFormValue(input.value);
            this.updateValidity(input.value);
        });
    }
    updateValidity(newValue)
    {
        if (newValue.length >= 2) {
            this.#internals.setValidity({ });
            return;
        }
        this.#internals.setValidity({tooShort: true}, 
            'value is too short', this.#shadowRoot.firstChild);
        this.#internals.reportValidity();
    }
}
customElements.define('some-text-field', SomeTextFieldElement);

通过此设置,当用户输入的字符数少于 2 时,:invalid 伪类将自动应用于该元素。

表单关联自定义元素回调

此外,表单关联自定义元素提供以下一组新的自定义元素响应回调:

  • formAssociatedCallback(form) – 当关联的表单元素变为 form 时调用。ElementInternals.form 返回关联的表单元素。
  • formResetCallback() – 当表单正在重置时调用(例如用户按下了 input[type=reset] 按钮)。自定义元素应清除用户设置的任何值。
  • formDisabledCallback(isDisabled) – 当元素的禁用状态改变时调用。
  • formStateRestoreCallback(state, reason) – 当浏览器尝试将元素状态恢复到 state 时调用,此时 reason 为“restore”;或者当浏览器尝试代表用户完成自动填充时调用,此时 reason 为“autocomplete”。在“restore”的情况下,state 是一个字符串、FileFormData 对象,之前作为 setFormValue 的第二个参数设置。

让我们以 formStateRestoreCallback 为例。在以下示例中,每当 shadow tree 内的输入元素的值更改时,我们都将 input.value 存储为状态(setFormValue 的第二个参数)。当用户导航到其他页面并返回到此页面时,浏览器可以通过 formStateRestoreCallback 恢复此状态。请注意,WebKit 当前有一个限制,即状态只能使用字符串,并且尚不支持“autocomplete”。

class SomeTextFieldElement extends HTMLElement {
    static formAssociated = true;
    #internals;
    #shadowRoot;
    constructor()
    {
        super();
        this.#internals = this.attachInternals();
        this.#shadowRoot = this.attachShadow({mode: 'closed', delegatesFocus: true});
        this.#shadowRoot.innerHTML = '<input autofocus>';
        const input = this.#shadowRoot.querySelector('input');
        input.addEventListener('change', () => {
            this.#internals.setFormValue(input.value, input.value);
        });
    }
    formStateRestoreCallback(state, reason)
    {
        this.#shadowRoot.querySelector('input').value = state;
    }
}
customElements.define('some-text-field', SomeTextFieldElement);

总之,ElementInternals 和表单关联自定义元素提供了一种令人兴奋的新方式来编写参与表单提交和验证的可重用组件。ElementInternals 还提供了为自定义元素指定 ARIA 角色和其他 ARIA 属性默认值的能力。我们很高兴能将这些功能带给 Web 开发者。