ElementInternals and Form-Associated Custom Elements

In Safari Technology Preview 162 we enabled the support for ElementInternals and the form-associated custom elements by default. Custom elements is a feature which lets web developers create reusable components by defining their own HTML elements without relying on a JavaScript framework. ElementInternals is a new addition to custom elements API, which allows developers to manage a custom element’s internal states such as default ARIA role or ARIA label as well as having custom elements participate in form submissions and validations.

Default ARIA for Custom Elements

To use ElementInternals with a custom element, call this.attachInternals() in a custom element constructor just the same way we’d call attachShadow() as follows:

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);

Here, #internals and #shadowRoot are private member fields. The above code will define a simple custom element whose ARIA role is button by default. Achieving the same effect without using ElementInternals required sprouting ARIA content attribute on the custom element itself like this:

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);

This code is problematic for a few reasons. For one, it’s surprising for an element to automatically add content attributes on itself since no built-in element does this. But more importantly, the above code prevents users of this custom element to override ARIA role like this because the constructor will override the role content attribute upon upgrades:

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

Using ElementInternals’s ariaRole property as done above, this example works seamlessly. ElementInternals similarly allows specifying the default values of other ARIA features such as ARIA label.

Participating in Form Submission

ElementInternals also adds the capability for custom elements to participate in a form submission. To use this feature of custom elements, we must declare that a custom element is associated with forms as follows:

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);

With the above definition of a some-button element, some-button will submit the value of the value attribute specified on the element for the name attribute specified on the same element. E.g., if we had a markup like <some-element name="some-key" value="some-value"></some-element>, we would submit some-key=``some-value.

Participating in Form Validation

Likewise, ElementInternals adds the capability for custom elements to participate in form validation. In the following example, some-text-field is designed to require a minimum of two characters in the input element inside its shadow tree. When there are less than two characters, it reports a validation error to the user using the browser’s native UI using setValidity() and reportValidity():

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);

With this setup, :invalid pseudo class will automatically apply to the element when the number of characters user typed is less than 2.

Form-Associated Custom Element Callbacks

In addition, form-associated custom elements provide the following set of new custom element reaction callbacks:

  • formAssociatedCallback(form) – Called when the associated form element changes to form. ElementInternals.form returns the associated from element.
  • formResetCallback() – Called when the form is being reset. (e.g. user pressed input[type=reset] button). Custom element should clear whatever value set by the user.
  • formDisabledCallback(isDisabled) – Called when the disabled state of the element changes.
  • formStateRestoreCallback(state, reason) – Called when the browser is trying to restore element’s state to state in which case reason is “restore”, or when the browser is trying to fulfill autofill on behalf of user in which case reason is “autocomplete”. In the case of “restore”, state is a string, File, or FormData object previously set as the second argument to setFormValue.

Let’s take a look at formStateRestoreCallback as an example. In the following example, we store input.value as state whenever the value of input element inside the shadow tree changes (second argument to setFormValue). When the user navigates away to some other page and comes back to this page, browser can restore this state via formStateRestoreCallback. Note that WebKit currently has a limitation that only string can be used for the state, and “autocomplete” is not supported yet.

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);

In summary, ElementInternals and form-associated custom elements provide an exciting new way of writing reusable component that participates in form submission and validation. ElementInternals also provides the ability to specify the default value of ARIA role and other ARIA properties for a custom element. We’re excited to bring these features together to web developers.