avatarDanny Moerkerke

Summary

Web developers can now create custom form controls that integrate seamlessly with native HTML forms using Web Components, leveraging the formdata event and the ElementInternals interface to enable form submission, validation, and autofill features.

Abstract

The article "Web Components Can Now Be Native Form Elements" provides a comprehensive guide on enhancing web forms with custom form controls using Web Components. Historically, customizing form controls has been challenging due to limited styling options and inconsistencies across browsers. However, with the advent of the formdata event and the ElementInternals interface, developers can create form-associated custom elements that behave like native form controls. These elements can be automatically included in form data upon submission, participate in form validation, and support features like autofill and label association. The guide explains how to use these interfaces to create accessible and user-friendly form controls, ensuring a seamless integration with the rest of the form elements, and improving the overall user experience, especially on mobile devices.

Opinions

  • The author emphasizes the importance of custom form controls being able to participate in native form functionalities such as form submission, validation, and autofill, which were previously difficult to replicate.
  • The author suggests that the ElementInternals interface is crucial for custom elements to be truly form-associated, allowing them to be included in the form's elements property and to have their values automatically submitted with the form.
  • It is noted that the combination of the formdata event and the ElementInternals interface provides a solution to long-standing challenges in creating custom form controls that function as native elements.
  • The article highlights the benefits of using custom form controls, such as increasing the touch area for focusing on elements, which is particularly advantageous on mobile devices.
  • The author provides a practical implementation example and a CodePen demonstration, indicating a preference for hands-on learning and experimentation.
  • The author encourages readers to engage with further resources, such as a follow-up article on native form validation of Web Components and a newsletter on modern web development topics, suggesting a commitment to ongoing education in the field.
  • The recommendation of an AI service at the end of the article implies an endorsement of tools that can enhance productivity or offer cost-effective alternatives to popular services like ChatGPT Plus.

Web Components Can Now Be Native Form Elements

Here’s the complete guide to customised form controls

Photo by Chris Hainto on Unsplash

Custom Form Controls

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 which 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 form, name, 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 change event.

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 with the following code:

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

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

And here’s a working codepen:

Be sure to check my follow-up article that explains how Web Components can participate in native form validation.

Join Modern Web Weekly, my weekly newsletter on the modern web platform, web components and Progressive Web Apps.

JavaScript
Web Development
Web Components
Programming
Coding
Recommended from ReadMedium