DEV Community

Cover image for Form-Associated Custom Elements
Benny Powers 🇮🇱🇨🇦
Benny Powers 🇮🇱🇨🇦

Posted on • Originally published at bennypowers.dev

Form-Associated Custom Elements

Form-Associated Custom Elements are a new web standard by which to build custom interactive form controls like buttons, inputs, checkboxes, etc. They present a path forward for design-systems and other custom element authors to more deeply integrate with the web platform. In this post, we'll build a simple FACE to get a feel for the APIs.

How Does this Help?

FACE adds crucial accessibility and interactivity features to web components, closing gaps between web components, framework components, and native browser controls. Before FACE, web component authors had to apply one of a number of workarounds each with their own trade-offs.

Teams developing FACEs can now implement accessible custom controls with simpler HTML APIs while retaining the benefits of Shadow DOM.

But before we get to the code, some history:

skip the history bit

How we Got Here

The web components v1 standard originally defined two kinds of custom elements. The most popular kind is called and autonomous custom element, and
it's what most people think about when they think of web components. The other kind is called a customized built-in element, and they look like this:

class XButton extends HTMLButtonElement {
  static is = 'x-button';
}
customElements.define(XButton.is, XButton, {
  extends 'button',
});
Enter fullscreen mode Exit fullscreen mode

You use CBIEs like this:

<button is="x-button">I'm an XButton</button>
Enter fullscreen mode Exit fullscreen mode

Notice the big differences here: XButton the customized built-in extends HTMLButtonElement, not HTMLElement, and when you register it, you have to pass both the custom element name x-button as well as the localName of the button element it extends. When using it in HTML, the localName of the element is button and the is attribute determines which subclass of HTMLButtonElement to upgrade with.

The chief advantage of customized built-ins was that they came with all the original features of their base elements, well, built-in. So a custom-element author wouldn't need to implement a bunch of stuff to make their custom textfield go, rather they could just extend the existing HTMLInputElement class and get all the necessary and expected functionality (especially crucial accessibility features) for free. Typical OOP stuff. So if customized built-ins are so great, how come this post isn't about them and how come we rarely see them?

Unfortunately, although customized built-ins remain a part of the spec, you should not build them. The reason for this is discouraging: despite the spec's ratification, Apple's WebKit team stated that they would decline to implement customized built-ins.

Since WebKit enjoys an artificial monopoly on iOS devices, the WebKit team's decision has an outsized effect on the industry. Think "US Electoral College", but for web browsers. Their decision not to implement makes customized built-ins a non-starter. Some prominent web developers (most notably Andrea Giammarchi) have advocated permanently adopting a polyfill, but the broader web components community has generally acquiesced to WebKit's decision.

Which is how FACE came to be, it's the alternative to CBIEs that the WebKit team championed.

Workarounds

Before FACE, page authors using custom elements had two options to submit forms with data from their web components:

  1. The "decorator pattern" - slotting native controls into autonomous custom elements
  2. Using JavaScript to manually submit form data

Each of these had their pros and cons.

The Decorator Pattern

The most versatile workaround for autonomous custom controls involves slotting native controls into the custom element.

<x-checkbox>
  <input type="checkbox">
</x-checkbox>
Enter fullscreen mode Exit fullscreen mode

The advantages to this approach include <noscript> support and hassle-free form participation. The disadvantages include HTML noise and awkward styling due to the current limitations of ::slotted(). This is compounded by the requirement to <label> elements, leading to stricter HTML requirements, copying nodes into the shadow root, producing hidden light DOM nodes, or other workarounds-for-the-workaround.

Manually Submitting Forms

Developers working on SPAs might opt instead to put their native inputs in the shadow DOM and use JavaScript to submit the form data to a JSON API. Here's a simple example of how that might work:

form.addEventListener('submit', function(event) {
  event.preventDefault();
  const body = JSON.stringify(somehowCollectFormValuesFromCustomControls());
  const { action, method = 'POST' } = form;
  fetch(action, { method, body });
})
Enter fullscreen mode Exit fullscreen mode

Given the right abstractions this approach could be quite productive for developers, but ties the controls to JavaScript.

Creating a FACE

Form-Associated Custom Elements solves one of the problems that is and customized built-in elements would have solved, namely, allowing your web component to participate in native web forms.

We create a FACE by setting the static formAssociated boolean flag and registering the custom element.

class XCheckbox extends HTMLElement {
  static formAssociated = true;
}

customElements.define('x-checkbox', XCheckbox);
Enter fullscreen mode Exit fullscreen mode

A Free Lunch

So what does this give us? Well, right off the bat, that one static class boolean adds a number of form-related behaviours to our otherwise plain element. The name, form, and disabled attributes now work the same as native <input> elements, and the presence of the readonly attribute will prevent the browser from trying to validate your field, although you're still responsible to make the control actually non-editable. Naming your FACE and specifying a form (by child composition or via form attribute) adds it to the form's HTMLFormControlsCollection, as well, if the element or it's containing <formset> has the disabled attribute, it will gain the CSS state :disabled.

<form>
  <fieldset disabled>
    <label for="xcheck">Check?</label>
    <x-checkbox id="xcheck"
                name="checkit"
                value="checkit"></x-checkbox>
  </fieldset>
</form>
Enter fullscreen mode Exit fullscreen mode

In the above snippet, our custom checkbox is disabled on account of its containing fieldset, and the form submits with its value on checkit. Removing disabled from the fieldset also unsets it from the element, without the element author needing to apply any extra code.

We also get some new lifecycle callbacks:

  • formAssociatedCallback(form: HTMLFormElement) runs when our element is associated with a <form>, either by being it's child or by setting the element's form attribute to the id of the form.
  • formDisabledCallback(state: boolean) runs when the element's disabled state changes, either because it or it's containing fieldset's disabled attribute changed.
  • formResetCallback() runs when the element's associated form has it's reset() method called. You can use this e.g. to reset to a default value.
  • formStateRestoreCallback(reason: 'autocomplete'|'restore') runs when the browser autofills the form. It takes a single argument of type 'autocomplete'|'restore', depending on whether the browser called it because of an autocomplete or a navigation.

All of that comes for free, even before implementing any actual custom control behaviour. So let's add in the actual checkbox stuff now, just like we would have done before the new standards.

Customizing the UI

Let's start by writing an accessor pair to link our element's checked property to the corresponding HTML attribute:

get checked() { return this.hasAttribute('checked'); }
set checked(x) { this.toggleAttribute('checked', x); }
Enter fullscreen mode Exit fullscreen mode

Built-in checkboxes set their value DOM property to either the value attribute's value or the string on, so let's do that too:

get value() { this.getAttribute('value') ?? 'on'; }
set value(v) { this.setAttribute('value', v); }
Enter fullscreen mode Exit fullscreen mode

We'll add checked and value to our observedAttributes list, then call our connectedCallback (providing a highly aesthetic UX), in attributeChangedCallback.

connectedCallback() {
  this.#container.textContent = this.checked ? '' : '';
}

attributeChangedCallback(name, _, value) {
  switch (name) {
    case 'checked': this.checked = value != null; break;
    case 'value': this.value = value; break;
  }
  this.connectedCallback();
}
Enter fullscreen mode Exit fullscreen mode

And last we'll add some keyboard and pointer interaction

constructor() {
  super();
  this.addEventListener('click', this.#onClick);
  this.addEventListener('keydown', this.#onKeydown);
  this.attachShadow({ mode: 'open' })
    .append(XCheckbox.template.cloneNode(true));
}


#onClick() {
  this.#toggle();
}

#onKeydown(event) {
  switch (event.key) {
    case ' ':
      event.preventDefault();
      this.#toggle();
  }
}

#toggle() {
  this.checked = !this.checked;
}
Enter fullscreen mode Exit fullscreen mode

Now that our checkbox looks and feels like a checkbox, the last thing to do is to hook into the browser's HTML form lifecycle with another new standard, ElementInternals.

Form Interactions

Along with FACE, ElementInternals gives custom element authors new capabilities. Specifically, element internals are a standard place to implement things like form control validation and accessibility. ElementInternals is designed as a catch-all bag of properties and methods for working with custom elements. We can expect expansions to its capabilities in the future, but for now it contains three parts:

  1. A reference to the element's shadow root, if it exists
  2. Form-related properties
  3. Accessibility-related properties

HTMLElement get a new standard method called attachInternals() which returns an ElementInternals object. This method may only be called on autonomous custom elements and will throw if called on built-ins, customized or otherwise. You hook your control's custom implementation into it's associated form with ElementInternals' form properties.

Let's create our ElementInternals object by callingattachInternals, and store it on a private class field.

#internals = this.attachInternals();
Enter fullscreen mode Exit fullscreen mode

Then, in our connectedCallback, we'll apply the checkbox' value to it's
FormData entry:

this.#internals.setFormValue(this.checked ? this.value : null);
Enter fullscreen mode Exit fullscreen mode

That setFormValue call is part of the ElementInternals secret sauce. Calling it with a non-nullish value adds our control's value to the form's FormData object, whereas calling it with null removes the value.

We can also implement form validation in our custom controls with the following internals properties and methods:

  • willValidate(): boolean checks whether the element will be validated when the form submits
  • setValidity() sets the element's form validity state
  • checkValidity() and reportValidity() work just like their native counterparts.

Custom validations are a big topic so let's save their more in-depth explanation for another day.

Accessibility

The other major feature of ElementInternals are it's a11y-related properties role and aria*. Part of the AOM, we can now set ARIA properties imperatively without needing to set aria- attributes. These are critical capabilities which previously only had partial workarounds.

Let's start by setting the role so that screen readers announce our element as a checkbutton. Note that as of this writing Firefox (107) has not yet implemented role reflection, so we'll do some feature detection

if ('role' in ElementInternals.prototype)
  this.#internals.role = 'checkbox';
else
  this.setAttribute('role', 'checkbox');
Enter fullscreen mode Exit fullscreen mode

We'll update our connectedCallback to render to the a11y tree as well as the DOM. Like role reflection, we'll apply a workaround for Firefox:

if ('ariaChecked' in ElementInternals.prototype)
  this.#internals.ariaChecked = String(this.checked);
else
  this.setAttribute('aria-checked', String(this.checked));
Enter fullscreen mode Exit fullscreen mode

Putting it all together, our custom checkbox:

  • implements it's own bespoke UI
  • participates in HTML forms like a native input
  • is accessible to users of assistive technologies

Check out the demo on the original post

Browser Support and Polyfills

As of initial publication, Chromium (Google Chrome, Microsoft Edge, Brave, Arc) supports the full range of APIs described here. Firefox supports attachInternals and formAssociated but does not support ARIA and role reflection. WebKit does not support any of the new APIs, but the commits to add support have been merged, so the next Safari Technology Preview is likely to add support.

Engine FACE ElementInternals AOM Reflection
Chromium
Firefox
WebKit

The inimitable Caleb D. Williams has kindly published an ElementInternals polyfill which weighs in at ~6kb over-the-wire. Since the spec involves hooking into browser stuff which is otherwise unavailable to developers, the polyfill is not 100% spec compliant. For example, ARIA reflection is implemented by adding aria- attributes to the host element, where the spec states that they should not be added. The polyfill also adds a workaround for the custom state part of the spec, which was not covered here.

Thoughts? Corrections? Comments? Let me know on mastodon.

Top comments (4)

Collapse
 
smalluban profile image
Dominik Lubański • Edited

Finally, the major drawback against custom elements will be gone! Nice sum up, thanks.

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

Here we go again; endless GitHub Issue posts "We want Apple to change their opinion on Customized Built-In Elements" and no one analyzing WHY Apple did not implement them... it is not that one Apple engineer one blue monday thought "Well, lets think different"

Collapse
 
bennypowers profile image
Benny Powers 🇮🇱🇨🇦

well, i linked to their reasoning in the post - people can make up their own minds. I'm of the opinion that their concerns were overblown and that either way it was not correct to go back on things once the standard was set

but that's just one web developer's opinion 🤷

regardless of the past, it's encouraging that some facility for FACE is coming online

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

well, i linked to their reasoning in the post - people can make up their own minds.

Only if they fully understand the Liskov Substitution Principle (which I don't)

Over the past 7+ years more knowledgeable developers than us have said:
"Well, maybe Apple is right on this one"

The root cause of all this, is Google throwing technologies against the wall (in Web Components V0) to see what sticks.

It is not just Apple taking a stance on Google-only parties; here is :host-context()

Personally I value opinions of Apple and Mozilla engineers over Google engineers.