Web Components Can Now Be Form Controls

Web Components Can Now Be Form Controls
Photo by Glenn Carstens-Peters / Unsplash

Modern Web Weekly #18

One area in which developers have always wanted to customize elements is forms.

Historically, it has often been hard to style form controls to give them the look and feel you want.

The styling options are often limited and to this day, form controls like date and color pickers are still inconsistent across browsers.

Many websites also need more advanced form controls that the native platform simply doesn’t provide (yet).

Web Components are ideal candidates for customised form controls because they are first-class HTML tags but unfortunately, they don’t provide the functionality of built-in form controls out of the box.

For example, when a form is submitted, customised form controls built with Custom Elements are not included in the form data.

Other functionalities like form validation and autofill are also not available to Custom Elements and are hard to replicate.

Luckily, there are two solutions available for these issues: the formdataevent and the ElementInternals interface.

FormData event

The formdata event is fired when a form is submitted and enables any JavaScript code to add arbitrary data to the form data that is submitted.

You can set an event listener for this event that will receive the event object with a formData property that contains all data being submitted by the form.

Each event listener can then add to or modify the data before it’s submitted:

const form = document.querySelector(‘form’);

form.addEventListener(‘formdata’, ({formData}) => {
  // add data
  formData.append(‘custom-control’, customValue);

  // modify data
  formData.set(‘email’, formData.get(‘email’).toLowerCase());
});

The formData property of the event is a FormData object.

ElementInternals interface

While the formData event is very handy, it is limited to adding arbitrary data to the data being submitted by the form.

For a Custom Element to be a true form control it should also be automatically associated with the form and participate in form validation.

That is what the ElementInternals interface is for.

It enables Custom Elements to be associated with a form so they are added to the elements property of the form and the value of the element will be automatically submitted with the form.

It also enables Custom Elements to participate in form validation.

The element can indicate whether its value is valid or invalid and prevent the form from being submitted when it’s invalid.

It can also be labeled with a <label> element which means the label is programmatically associated with the form element.

When a user clicks or taps the label, the form element will receive focus.

This increases the area for focusing on the element that provides a good user experience, especially on mobile devices.

It will also cause screen readers to read out the label when the user focuses on the form element.

Associating a Custom Element with a form

The first step to creating a custom form control with a Custom Element is to associate it with the form.

This means it will be part of the form’s elements property, which is an HTMLFormControlsCollection containing all form controls contained by the form.

In the previous example, you have seen how you can add entries to a FormData object using its append method.

It’s also possible to create a FormData object directly from all elements and their values in a form by passing the form to the FormData constructor:

const form = document.querySelector(‘form’); 
// formData now contains all data of form
const formData = new FormData(form);

formData now contains all data of the form including any Custom Elements that have been associated with the form.

Here’s the class of a form-associated Custom Element:

class FormInput extends HTMLElement {
  static formAssociated = true;
 
  constructor() {
    super();
    this.internals = this.attachInternals();
  }
  get value() {
    return this._value;
  }
  set value(value) {
    this._value = value;
    this.internals.setFormValue(value);
  }
  get form() {
    return this.internals.form;
  }
 
  get name() {
    return this.getAttribute(‘name’);
  }
 
  get type() {
    return this.localName;
  }
}

This example shows a minimal implementation of a form-associated Custom Element. Let’s look at the individual parts.

static formAssociated = true;

By setting the static formAssociated property to true, the Custom Element will be associated with the form and it will be included in the form’s elements property.

This won’t however include the value of the Custom Element in the form data yet.

For that, the ElementInternals interface will need to be attached to the element:

this.internals = this.attachInternals();

attachInternals returns the ElementInternals object which is then stored in the internals property of the Custom Element.

In order to get the value of the Custom Element that will be submitted with the form, this value needs to be set with:

this.internals.setFormValue(value);

In the example, this line has been added to the setter for the value property to make sure that whenever value is set, its form value will also be set.

This is the value that will be submitted with the form.

The other getters for formname, and type are standard properties that native form elements have.

This is the bare minimum your component should have to become a custom form element.

Of course, this component doesn’t render anything yet so it’s not very useful. Let’s change that by adding a Shadow Root with an <input> element:

constructor() {
  super();
  this.internals = this.attachInternals();
  const shadowRoot = this.attachShadow({mode: ‘open’});
  shadowRoot.innerHTML = `
    <style>
      :host {
        display: inline-block;
      }
      input {
        display: block;
        padding: 5px;
      }
    </style>
    <input type=”text”>
 `;
}

Now that the component has an <input>, we first need to make sure that the value of the input is available as the value of the component that is submitted with the form.

Recall that we created a setter for the value property that calls this.internals.setFormValue(value).

We can get the value of the input each time it changes through its changeevent.

When this event is fired, we simply set the value property of our component which will call the setter which calls this.internals.setFormValue(value).

Let’s add the needed event listener:

this.input = this.shadowRoot.querySelector(‘input’);
this.input.addEventListener(‘change’, (e) => {
  this.value = this.input.value;
});

Now every time the value of the input changes, the value of the component is updated and the correct value will be submitted with the form.

Of course, the other way around should also work: when the value property of the component is set, the value property of the <input> should also be set.

The setter should be changed to this:

set value(value) {
  this._value = value;
  this.input.value = value; // set the value of the input 
  this.internals.setFormValue(value);
}

And the code that uses our component will also expect a change event to be fired since it now contains an <input> element.

Therefore, the event from the <input> should be forwarded.

We can’t simply dispatch it again, since that will throw an error saying the event was already dispatched but we can clone it and then forward it:

this.input.addEventListener(‘change’, (e) => {
  const clone = new e.constructor(e.type, e); // clone the event
  this.dispatchEvent(clone); // and then forward it
  this.value = this.input.value;
});

Now the value of the component and the input are kept in sync and the component fires a change event.

Labeling the custom form control

Now that you have associated your Custom Element with the form, you can also associate it with a <label>.

The benefit of this is that the Custom Element will also be programmatically associated with the label.

This means, for example, that a screen reader will read out the label text when a user focuses the input and that when the label is clicked or tapped, the associated input will be focused.

This increases the touch area of the input, making it easier to focus, especially on a mobile device.

There are two ways of associating a form control with a <label>.

The simplest is to place the form control inside the <label>.

This makes the association very clear:

<label>
  City
  <input type=”text” name=”city”>
</label>

The other way is to use the for attribute of the <label>.

The value of this attribute should be the id of the form control you want to associate it with:

<label for=”city”>City</label>
<input type=”text” id=”city”>

This may come in handy when the label and the form control are in different places in the DOM or when you can’t place the control inside the label for some reason.

There are two additional things that need to be done to make clicking the label focus the <input> inside your custom form control.

First, the component should be made focusable. This is done by adding a tabindex attribute.

This attribute indicates that an element is focusable and the order in which elements will be focused when a user uses the TAB key to navigate through form controls.

Normally, the focus order relies on the order in which the elements occur in the DOM but with tabindex this can be altered.

It means that for example an element with tabindex 4 will be focused before an element with tabindex 5 but after an element with tabindex 3.

When two elements have the same tabindex the order in the DOM takes precedence so if you want to keep this order but need to add tabindex to make elements focusable, you can use tabindex="0" for all of them.

To make sure the element always has a tabindex attribute, you can check if it’s present and add it if it’s not:

if (!this.hasAttribute(‘tabindex’)) {
  this.setAttribute(‘tabindex’, ‘0’);
}

Now that your component is focusable, it will be focused when the <label>it’s associated with is clicked or tapped.

But since the <input> inside it needs to be focused, the focus should be delegated. This can be done by attaching a Shadow Root with the delegatesFocus option:

const shadowRoot = this.attachShadow({mode: ‘open’, delegatesFocus: true});

or by setting an event handler to propagate focus:

this.addEventListener(‘focus’, () => this.input.focus());

The difference is that with delegatesFocus any text inside the element will be selected as well while the second approach only focuses it.

Now when the <label> is clicked, the <input> inside the component will be focused.

Here’s the complete component:

class FormInput extends HTMLElement {
  static formAssociated = true;
  constructor() {
    super();
    this.internals = this.attachInternals();
    const shadowRoot = this.attachShadow({mode: ‘open’});
    shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }
        input {
          display: block;
          padding: 5px;
        }
      </style>
      <input type=”text”>
   `;
 }
  connectedCallback() {
    this.input = this.shadowRoot.querySelector(‘input’);
    this.input.addEventListener(‘change’, (e) => {
      const clone = new e.constructor(e.type, e);
      this.dispatchEvent(clone);
      this.value = this.input.value;
    });
    this.addEventListener(‘focus’, () => this.input.focus());
    if (!this.hasAttribute(‘tabindex’)) {
      this.setAttribute(‘tabindex’, ‘0’);
    }
  }
  get value() {
    return this._value;
  }
  set value(value) {
    this._value = value;
    this.internals.setFormValue(value);
  }
  get form() {
    return this.internals.form;
  }
 
  get name() {
    return this.getAttribute(‘name’);
  }
 
  get type() {
    return this.localName;
  }
}

I created a mixin that associates a web component with a form so it participates in form submission and validation. It takes care of adding all the necessary code so you can use it like any other native form control, including showing custom error messages and custom display of errors that can be styled with CSS.

Check out the repo!

🔗
Got an interesting link for Modern Web Weekly?
Send me a DM on Twitter to let me know!