Finally, custom form elements that don't suck!

Finally, custom form elements that don't suck!

Featured on Hashnode

In the world of web development, creating custom form elements has always been a bit of a challenge. However, with the introduction of web components and the use of the ElementInternals API, this task has become much easier. By leveraging the power of web components, developers can now create custom form elements that can be used in any website or application. In this blog post, we will explore what the ElementInternals API is and how you can use it to create custom form elements.

The Problem

One of the main challenges that developers face when creating web components is making them work seamlessly with HTML forms. By default, web components are not recognized as form elements, which means that they cannot be submitted and don't show up in FormData. So even if your custom component wraps a native input in its ShadowDOM, it will not be picked up by a form outside the Shadow DOM. You could pass a native input into the component from the outside via the slot element, but this creates numerous new problems with accessing and styling that input from inside the web component. This also means they won't work out of the box with common form libraries.

ElementInternals to the rescue

This API provides a set of methods and properties that allow developers to customize the behavior of web components when used as form elements. It is supported by all major browsers, including Chrome ( v77), Firefox (v93), Safari (v16.4), and Edge (v79). See caniuse. If you need to support older browsers there is a polyfill with limited functionality available: https://www.npmjs.com/package/element-internals-polyfill.

As there are numerous properties and methods available, let's focus on the most significant ones:

  • Start using ElementInternals in your web component
  • Ensure value and validity
  • Focus events

Start using ElementInternals in your web component

There is essentially two things we have to do, to use the ElementInternals API:

  • Flag the web component as being formAssociated
  • Attach the ElementInterals in the constructor of our web component

I'm putting together a showcase for a design system and will be using examples written in Lit, but they should be easily adaptable to plain web components. You can find the project on Github.

Below you can see the most basic example of allowing a web component to be associated with a form as well as attaching the ElementInternals to use internally.

import {customElement} from 'lit/decorators.js';
import {LitElement} from 'lit';

@customElement('dss-switch')
export default class Switch extends LitElement {
  static formAssociated = true;

  private internals: ElementInternals;

  constructor() {
    super();
    this.internals = this.attachInternals();
  }
}

Now our web component is capable of being included in a form. We can set a name attribute and it will show up in FormData. But without a value this will not help us much yet.

Ensure value and validity

A form element usually exposes a value property. Since many 3rd party form libraries rely on this, our component should also have an exposed value property. Let's say our Switch component from above is actually just rendering a checkbox inside. Due to this, we will also expose a property checked just like the native checkbox. All we want to do now is replicate these properties from our native checkbox on our component.

import {customElement, property} from 'lit/decorators.js';
import {LitElement} from 'lit';
import {createRef, Ref, ref} from 'lit-html/directives/ref.js';

@customElement('dss-switch')
export default class Switch extends LitElement {
  static formAssociated = true;

  @property({type: Boolean})
  public checked = false;

  @property()
  public value?: string;

  private internals: ElementInternals;
  private inputRef: Ref<HTMLInputElement> = createRef();

  constructor() {
    super();
    this.internals = this.attachInternals();
  }

  protected render() {
    return html`
      <input 
        ${ref(this.inputRef)}
        type="checkbox"
        ?checked="${this.checked}"
        @change=${(event: Event) => this.handleCheckboxChange(event)}
      >
    `;
  }

  private handleCheckboxChange(event: Event) {
    const checkbox = event.target as HTMLInputElement;
    this.checked = checkbox.checked;
    this.value = checkbox.value;
    this.dispatchEvent(new Event('change', event));
  }

  protected updated(changedProperties: PropertyValues): void {
    if (changedProperties.has('checked')) {
      this.internals.setFormValue(this.checked ? 'on' : null);
      this.internals.setValidity(
        this.inputRef.value!.validity,
        this.inputRef.value!.validationMessage,
        this.inputRef!.value,
      );
    }
    super.updated(changedProperties);
  }
}

Let's break this example down. First, we render the native input and pass the property checked down to it. This means, when we set that property on our web component, it will update the native checkbox.

Next, we set up an event listener for the change event on the checkbox, which will trigger a handler function. Within this function, we will modify the checked and value properties of our component based on the changes made to the native checkbox. Since change events do not travel through the ShadowDOM we have to re-dispatch the event.

Lastly, whenever our components checked property changes we will update the ElementInternals. The native checkbox has null or on set as the value, so we replicate this behavior in our component. Updating the validity does not make a lot of sense yet, but later if we would support things like required we could take the native checkbox validation messages. This means when trying to submit the form our component would report the validity of the native checkbox.

Focus events

Many 3rd party form libraries allow you to run validations on change or on blur. This means our web component needs to dispatch those events. For our switch component, we already dispatch a change event, and we won't have to worry about focus/blur events. Since we have a native input in our ShadowDOM, our component will dispatch focus and blur events when the native input receives or loses focus.

There is an edge case which you can see in the buttongroup component here. When we slot elements, the focus on these slotted children will take away focus from our component. Or in the case of the buttongroup component, we will never get focus since there are no elements that can receive focus inside our ShadowDOM.

Luckily we can use the focusin and focusout events that bubble up through the slot and dispatch our own focus/blur events on our component. Since the buttongroup component has multiple buttons inside the slot we get a focusout and a focusin whenever the user switches focus from one button to the other. Therefore, we needed to add a little more logic to not dispatch too many events.

Conclusion

As you can see in the Github project, adding form capabilities to your web components became quite easy with the new ElementInternals API. It allows us to customize the behavior of our components in forms and let them work seamlessly with native forms as well as 3rd party form libraries.