DEV Community

Cover image for Form-associated custom elements FTW!
Stephen Belovarich
Stephen Belovarich

Posted on

Form-associated custom elements FTW!

Before Shadow DOM, you needed a framework to encapsulate component templates or styling. Shadow DOM was a game-changer because it allows you to code UI components without their logic clashing with other components using just the web platform. Shadow DOM poses challenges when HTML elements encapsulated by Shadow DOM need to participate in a form.

In this post, I'll provide an overview of form-associated custom elements. Form-associated custom elements is a web specification that allows engineers to code custom form controls that report value and validity to HTMLFormElement, while also promoting a fully accessible user experience.

The Problem

With the encapsulation provided by Shadow DOM, engineers can code UI components where the CSS styling doesn't collide with other components. Shadow DOM provides a DOM tree for an element separated from the rest of the Document Object Model (DOM). The separation of concerns promoted by Shadow DOM is a boon for coding reusable UI components.

While Shadow DOM has several benefits, there are some complications when elements embedded in Shadow DOM have to interact with HTMLFormElement. Suppose you wanted to code a custom checkbox component using Shadow DOM. Checkboxes usually require a significant amount of CSS styling that overrides the browser defaults to match a given mockup. You code an autonomous custom element and style the HTMLInputElement with type="checkbox" in the context of Shadow DOM so the styling doesn't conflict with other elements. You give the component a tag name of my-checkbox. Just when you think you're following best practices, you place an instance of the custom element as a child of HTMLFormElement.

<form>
  <my-checkbox></my-checkbox>
</form>
Enter fullscreen mode Exit fullscreen mode

Upon inspection in Dev Tools, you may notice the HTMLInputElement cannot participate with the form. You can inspect this phenomenon in this CodeSandbox.

HTMLInputElement by design can report value and validity back to HTMLFormElement, but only when HTMLInputElement is a direct descendent of HTMLFormElement.

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

When coding reusable components it's a good idea to provide an interface for web engineers that's familiar. It's typical for HTMLInputElement that are direct descendants of HTMLFormElement to have access to the parent form directly on the element. You can inspect this behavior in the following CodeSandbox.

Since the HTMLInputElement is found in an entirely different DOM tree (Shadow DOM), the HTMLFormElement doesn't recognize the HTMLInputElement.

my-checkbox contains a HTMLInputElement embedded in Shadow DOM

In short, HTMLInputElement embedded in Shadow DOM can't participate in the form.

Enter Form-associated custom elements

In 2019, a new specification was proposed that solves this issue. Form-associated custom elements allow web engineers to use the benefits of Shadow DOM while providing an API that enables custom elements to participate in HTMLFormElement. Form-associated custom elements have all the benefits of autonomous custom elements. They can implement Shadow DOM and use the typical custom lifecycle hooks because they inherit from HTMLElement.

If you've coded autonomous custom elements, learning how to code form-associated custom elements is fairly similar. In the following examples, I'll demonstrate how a checkbox embedded in Shadow DOM can participate in an HTML form by implementing formAssociated and ElementInternals.

Before I mentioned that form controls that are native to the browser like HTMLInputElement automatically participate in HTMLFormElement. The form control is added to an Array-like interface on HTMLFormElement, allowing web engineers to loop through the form controls to handle common tasks like validation.

For the Checkbox component to participate in HTMLFormElement the same way, you simply need to set the value of a static property named formAssociated to true. the below example written with TypeScript does just that.

If you wish to follow along, fork this CodeSandbox and start coding.

export class Checkbox extends HTMLElement {
  static formAssociated = true;
  constructor() {
    ....
Enter fullscreen mode Exit fullscreen mode

Inversely, if you wish to reference HTMLFormElement on instances of Checkbox, similar to how HTMLInputElement behaves when it's a direct descendant of HTMLFormElement, a method inherited from HTMLElement called attachInternals can be called with provides the same interface, along with the Accessibility Object Model (AOM). By setting a property on Checkbox named _internals to what's returned by attachInternals, you effectively add the ElementInternals interface to Checkbox.

export class Checkbox extends HTMLElement {
  _internals: ElementInternals;
  static formAssociated = true;
  constructor() {
    super();
    this._internals = this.attachInternals();
Enter fullscreen mode Exit fullscreen mode

Parity

Later in this post, I'll provide an example of how you can reference a method on the ElementInternals interface that aids with validation. Before that, we should resolve some discrepancies between Checkbox and a typical HTMLInputElement. If we expect engineers to reuse this component, it should behave similarly to HTMLInputElement, which has a well-known interface. To provide parity between HTMLInputElement and Checkbox, let's define some getters and setters on Checkbox. First, make a getter that returns a reference of the HTMLInputElement so you can easily reference the element with this.checkbox throughout the logic of the component.

  get checkbox(): HTMLInputElement {
    return this.shadowRoot?.querySelector("input") as HTMLInputElement;
  }
Enter fullscreen mode Exit fullscreen mode

Next, define a getter and setter for the state of the checkbox. It's probably a good idea to make the HTMLInputElement the single source of truth here. Any getter and setter defined on Checkbox either returns the value or sets the value of checked on this.checkbox.

  get checked(): boolean {
    return this.checkbox.checked;
  }
  set checked(state: boolean) {
    this.checkbox.checked = true;
  }
Enter fullscreen mode Exit fullscreen mode

We could introduce several more properties on Checkbox to provide parity between it and a typical HTMLInputElement, but we'll stop there for now.

Lessons Learned

While coding a UI library filled with form-associated custom elements, I found a couple of challenges in making the components reusable. Suppose you wanted to add validation logic to Checkbox and have the class another method called onValidate, including all the logic there. In the below example, I call setValidity on the ElementInternals interface, which reports the validity of the input to HTMLFormElement. This is convenient, however placing this logic here doesn't give a web engineer the ability to configure validations per business logic in different scenarios.

  onValidate(state: boolean) {
    if (!state) {
      this._internals.setValidity({ customError: true }, "required");
    } else if (state === true) {
      this._internals.setValidity({});
    }
  }
Enter fullscreen mode Exit fullscreen mode

A higher-level validation pattern is required that would allow engineers to loop through form controls and validate an entire form.

Another challenge had to do with making inline validation messages accessible. Getting screen readers to interpret validation messages as errors that should be read aloud at first seems tricky because of Shadow DOM, although is possible using WAI-ARIA attributes. Suppose this were the template instead of just the input. If the form control is invalid, custom logic could populate the <div class="message"> with relevant content. WAI-ARIA attributes provide an immediate response for screen readers.

   <div class="control">
      <input type="checkbox" aria-describedby="message" />
    </div>
    <div class="message"
         aria-role="alert"
         aria-live="assertive"
         id="message"
    ></div>
Enter fullscreen mode Exit fullscreen mode

Autonomous custom elements that employ Shadow DOM provide encapsulation for component template and styling. Form-associated custom elements level up custom elements by allowing them to participate in a HTML form much like the "native" HTMLInputElement. Form-associated custom elements open the doors for custom user experiences like this joystick component, which reports it's value to HTMLFormElement like any other form control.

Image description

Form-associated custom elements are both practical and transformative, allowing engineers to code reusable form controls without the need for JavaScript frameworks and libraries. If you want to code a highly performant user interface that depends heavily on forms, form-associated custom elements are where it's at!

Fullstack Web Components

Are you looking to code Web Components now, but don't know where to get started? I wrote a book titled Fullstack Web Components, a hands-on guide to coding UI libraries and web applications with custom elements. In Fullstack Web Components, you'll...

  • Code several components using autonomous, customized built-in, and form-associated custom elements, Shadow DOM, HTML templates, CSS variables, and Declarative Shadow DOM
  • Develop a micro-library with TypeScript decorators that streamlines UI component development
  • Learn best practices for maintaining a UI library of Web Components with Storybook
  • Code an application using Web Components and TypeScript
  • Server-side render Web Components with Node.js and @lit-labs/ssr

Fullstack Web Components

Fullstack Web Components is available now on newline.co.

Top comments (0)