Custom Forms with Web Components and "ElementInternals"
Tags: webcomponents, elementinternals, forms, javascript
Published: 2023-07-14 02:02:01

---
title: Custom Forms with Web Components and "ElementInternals"
published: true
description: The ElementInternals API allows us to create form-associated custom elements which we can use to make our own supercharged form fields.
tags: webcomponents, ElementInternals, forms, JavaScript
# cover_image: https://raw.githubusercontent.com/break-stuff/blog-images/main/web_components_love_forms.png
# Use a ratio of 100:42 for best results.
# published_at: 2023-07-12 02:10 +0000
---

With the release of Safari 16.4 in March 2023, web components reached a milestone in their capability to interact with the `<form>` element using the [`ElementInternals` API](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals). Prior to this, input elements located within a [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_shadow_DOM) were not discoverable by forms, which means they would not validate on form submission and their data would not be included in the `FormData` object. The `ElementInterals` API allows us to create Form-Associated Custom Elements (FACE for short). Our custom elements can now behave like native input elements and take advantage of form APIs like [form constraint validation](https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation).

I wanted to explore the capabilities of the API so we could implement it in our component library at work, but as I began working on it, I couldn’t always see a clear path forward, so I decided to write this article for anyone else running into the same problems. I will also use TypeScript in the examples to help define the APIs.

## Getting Started

If you already have components set up or if you are not following along on your own, please free to jump to the “Associating Custom Elements with Forms” section below. There are CodePens available to help you skip ahead. If not, [here is a CodePen](https://codepen.io/break-stuff/pen/mdQpRqx?editors=1010) you can get started with.

### Setting Up the Component

For the purposes of demonstrating the `ElementInternals` API, our component setup will be *very* simple with only two attributes initially - `value` and `required`. This example will be using “vanilla” web components, so your component setup will likely differ if you are using a library or framework to build your components. Be sure to follow those best practices when setting up your components. The good news is that implementing the `ElementInternals` API seems to be fairly consistent regardless of the tools you are using.

### Add Input

The first thing we are going to do is use the component’s `connectedCallback` lifecycle hook to add a shadow root to our component and insert an input and label in it.

```tsx
customElements.define('my-input', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <label>
        My Input
        <input type="text" />
      </label>`;
  }
});
```

Let’s grab a reference to our input element. We can do that by creating a property at the root of our class (I am choosing to prefix this with a `$`  to distinguish properties referencing HTML elements from other properties).

```tsx
private $input: HTMLInputElement;
```

In our `connectedCallback` method, we can query the shadow DOM contents for our input element.

```tsx
connectedCallback() {
	...
	this.$input = this.shadowRoot.querySelector('input');
}
```

### Add Attributes

Let’s add `value` and `required` attributes so we can set them on our custom element’s tag and pass them to our internal element (`<my-input value=”abc” required></my-input`). We will do this by adding the `observedAttributes` property and returning an array of the attribute names.

```tsx
static get observedAttributes() {
  return ["required", "value"];
}
```

Now we need to update the input properties when the attributes change using the `attributeChangedCallback` lifecycle hook, but we will have a timing issue. The `attributeChangedCallback` method will run before our internal input has had a chance to render and for our query selector to assign it to our `$input` variable. To get around this, we will create a component variable to capture the attributes and values and we will update our internal input when it is ready.

First, let’s add a private property called `_attrs` to our component and set the value to an empty object.

```tsx
private _attrs = {};
```

In our `attributeChangeCallback` method, let’s assign any attribute changes to that object where `name` is the attribute being changed and `next` is the new value.

```tsx
attributeChangedCallback(name, prev, next) {
  this._attrs[name] = next;
}
```

Now let’s create a private method that will use our `_attrs` values to update our input element.

```tsx
private setProps() {
  // prevent any errors in case the input isn't set
  if (!this.$input) {
    return;
  }

  // loop over the properties and apply them to the input
  for (let prop in this._attrs) {
    switch (prop) {
      case "value":
        this.$input.value = this._attrs[prop];
        break;
      case "required":
        const required = this._attrs[prop];
        this.$input.toggleAttribute(
          "required",
          required === "true" || required === ""
        );
        break;
    }
  }

  // reset the attributes to prevent unwanted changes later
  this._attrs = {};
}
```

We can now add this to the `connectedCallback` method to update our input element with any changes that happened before it was rendered.

```tsx
connectedCallback() {
  ...
  this.$input = shadowRoot.querySelector('input');
  this.setProps();
}
```

We can also add this to the `attributeChangedCallback` method so any attribute changes that occur after the `connectedCallback` method gets called are applied to the input element.

```tsx
attributeChangedCallback(name, prev, next) {
  this._attrs[name] = next;
  this.setProps();
}
```

Now we should be able to add attributes to our element tag and have them pass down to the internal input element being rendered.

{% codepen https://codepen.io/break-stuff/pen/jOQwbyy default-tab=js %}

## Associating Custom Elements with Forms

Associating your custom element is surprisingly straightforward and can be done in 2 steps:

1. set the `formAssociated` static property to `true`

2. Expose the `ElementInternals` API by calling `this._internals = this.attachInternals();` in the component’s constructor.

```tsx
static formAssociated = true;
private _internals: ElementInternals;
  
constructor() {
  super();
  this._internals = this.attachInternals();
}
```

With those two changes, our custom element can now see the parent form! As a quick test try adding `console.log(this._internals.form);` to the `connectedCallback` method and you should see the parent logged in the console.

### Using Labels

By making this a form-associated custom element, the browser now sees it as an input element which means we can move the label out of our component. 

```tsx
connectedCallback() {
  ...
  shadowRoot.innerHTML = `<input type="text" />`;
}
```

We can label our custom input element like a standard input element. Let’s use a `<label>` and reference it using an `id` on the custom element and a `for` attribute on the label. 

```html
<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input"></my-input>
</form>
```

> ***NOTE:*** There is an accessibility bug in [Safari](https://bugs.webkit.org/show_bug.cgi?id=259124) and [NVDA](https://github.com/nvaccess/nvda/issues/15118) where labels are not properly associated to Form-Associated Custom Elements for screen readers. They are read fine with VoiceOver in Chromium browsers (Chrome, Edger, Brave, etc.) and Firefox on Mac and MS Narrator and JAWS on Windows. As a workaround, you can continue including your labels within your elements.

Let’s also update our shadow root configuration to delegate focus. This will allow the input to be focused when the label is clicked like with native input elements.

```tsx
const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true });
```

> “If `delegatesFocus` is `true`, when a non-focusable part of the shadow DOM is clicked, or `.focus()` is called on the host element, the first focusable part is given focus, and the shadow host is given any available `:focus` styling.” - MDN

### Exposing Validation

Now that the form is associated with our custom element, we can begin by exposing some of the core validation behavior we would expect with an input element like `checkValidity`, `reportValidity`, `validity`, and `validationMessage`.

```tsx
public checkValidity(): boolean {
  return this._internals.checkValidity();
}

public reportValidity(): void {
  return this._internals.reportValidity();
}

public get validity(): ValidityState {
  return this._internals.validity;
}

public get validationMessage(): string {
  return this._internals.validationMessage;
}
```

### Controlling Validation

The `ElementIntenrals` API gives us access to the `setValidity` method, which we can use to communicate to the form the validity status of our element. 

```tsx
setValidity(flags: ValidityStateFlags, message?: string, anchor?: HTMLElement)
```

As you can see, the `message` and `anchor` attributes are optional. If you want to reset the validation, you can pass an empty object (`{}`) as the flags parameter.

#### Flags

The `flags` interface is almost identical to the `ValidityState` object you get when you call `input.validity`, but each of the properties is optional and can be set (where an input’s `validity` is a read-only property). Here is an example of the interface as well as some examples of when these would be set with native HTML input elements.

```tsx
interface ValidityStateFlags {
  /** `true` if the element is required, but has no value */
  valueMissing?: boolean;
  /** `true` if the value is not in the required syntax (when the "type" is "email" or "URL") */
  typeMismatch?: boolean;
  /** `true` if the value does not match the specified pattern */
  patternMismatch?: boolean;
  /** `true` if the value exceeds the specified `maxlength` */
  tooLong?: boolean;
  /** `true` if the value fails to meet the specified `minlength` */
  tooShort?: boolean;
  /** `true` if the value is less than the minimum specified by the `min` attribute */
  rangeUnderflow?: boolean;
  /** `true` if the value is greater than the maximum specified by the `max` attribute */
  rangeOverflow?: boolean;
	/** `true` if the value does not fit the rules determined by the `step` attribute (that is, it's not evenly divisible by the step value) */
  stepMismatch?: boolean;
  /** `true` if the user has provided input that the browser is unable to convert */
  badInput?: boolean;
  /** `true` if the element's custom validity message has been set to a non-empty string by calling the element's `setCustomValidity()` method */
  customError?: boolean;
}
```

#### Message

The `message` parameter is how we can provide the element with a custom error message whenever one of these validation parameters is validated.

#### Anchor

The `anchor` parameter is the element which we want to associate the error message with.

## Adding Validation

Now that we can control how validation is set in our component, let’s add the functionality to make our input `required`.

### Initialize Validation

Now let’s initialize the validation. Because our input’s `ValidityState` has essentially the same interface as our `ValidityStateFlags` we can use the input’s initial state to set the `ElementInterals` state. Right after our input selector in the `connectedCallback` method, let’s call the `setValidity` method based on our input.

```tsx
connectedCallback() {
  ...
  this.$input = shadowRoot.querySelector('input');
  this._internals.setValidity(this.$input.validity, 
  this.$input.validationMessage, this.$input);
}
```

Here we use the internal input element’s `ValidityState`, but you can also pass in a subset of the `ValidityStateFlags` and a custom error message as well.

```tsx
this._internals.setValidity(
  {
    valueMissing: true
  }, 
  'Please fill out this required field', 
  this.$input
);
```

### Testing Validation

Everything should be wired up, so let’s test it out. Let’s update the HTML to add a submit button and a `required` attribute to our custom element.

```html
<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input" required></my-input>
  <button>Submit</button>
</form>
```

When you click the “Submit” button, you should see the browser validation message for a required field. If we select our form and call `form.checkValidity()`, it should return `false`.

### Updating Validation Using `setValidity`

That validation state will remain as it is until we update it. If you enter text in the input and click “Submit”, you will still see an error message and `form.checkValidity()` will still return `false`.

For this demonstration, we can set up a simple update whenever the user inputs content in the field. To do that, we will add an event listener to our `connectedCallback` method after we have selected our input element.

```tsx
connectedCallback() {
  ...
  this.$input.addEventListener('input', () => this.handleInput());
}

private handleInput() {
  this._internals.setValidity(this.$input.validity, this.$input.validationMessage, this.$input);
}
```

{% codepen https://codepen.io/break-stuff/pen/jOQwbGm default-tab=js %}

## Updating Form Values Using `setFormValue`

Using the `setFormValue` on the `ElementInternals` API, we can now update our form whenever the value changes in our custom element. This allows developers to easily get form values using the `FormData` API.

Let’s set the initial value when the component loads in the `connectedCallback` method.

```tsx
connectedCallback() {
  ...
  this._internals.setFormValue(this.value);
}
```

Now, let’s update the value any time the `input` event fires by adding the update to our event listener.

```tsx
private handleInput() {
  ...
  this._internals.setFormValue(this.value);
}
```

### Testing Form Data

To test our values, let’s update our component to include a `name` attribute for the form to identify it.

```html
<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input" name="myInput" required></my-input>
  <button>Submit</button>
</form>
```

We can now test to see if our value is being bound to the form by adding an event listener to the form’s `submit` event and grabbing the form data.

```tsx
const form = document.getElementById("my-form");
form.addEventListener("submit", (e) => {
  e.preventDefault();
  const formData = new FormData(e.target);
  console.log(`My Input Value - '${formData.get('myInput')}'`);
});
```

Type a value into the input and click the “Submit” button. You should see your value logged in the console.

{% codepen https://codepen.io/break-stuff/pen/vYQJzWL default-tab=js %}

## ElementInternals Lifecycle Hooks

The `ElementInternals` API provides us with some additional lifecycle hooks that are important for controlling interactions with the browser and other elements. It is important to note that these are optional and should be used only when necessary.

### `formAssociatedCallback(form: HTMLFormElement)`

This is called as soon as the element is associated with a form. We don’t really have a need for this right now so we won’t implement this right now.

### `formDisabledCallback(disabled: boolean)`

This is called whenever the element or parent `<fieldset>` element are disabled. We can use this to help manage the disabled state of our internal element. We will add the callback method to the class and update our internal input element when it changes.

```tsx
formDisabledCallback(disabled: boolean) {
  this.$input.disabled = disabled;
}
```

### `formResetCallback()`

This gives us the ability to control our element’s behavior when a user resets a form. In our case, we will keep it simple and reset the input value to whatever the initial value was when the component was loaded. We will create a private property called `_defaultValue`, set it in the `connectedCallback` method, and then use the `formResetCallback` callback method to reset the value if the form is reset.

```tsx
private _defaultValue = "";

connectedCallback() {
  ...
  this._defaultValue = this.$input.value;
}

formResetCallback() {
  this.$input.value = this._defaultValue;
}
```

Let’s update our form to include a reset button and add an initial value to the input. Now we can change the value and press the “Reset” button and it will revert back to the original value.

```tsx
<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input" name="myInput" value="test" required></my-input>
  <button type="reset">Reset</button>
  <button>Submit</button>
</form> 
```

### **`formStateRestoreCallback(state, mode)`**

This method callback gives the developer control over what happens when the browser completes form elements. The `state` property provides the value that is set using the `setFormValue` and the `mode` has two possible values - `restore` and `autocomplete`. The `restore` value is set when a user navigates away from a form and back to it again allowing them to continue where they left off. The `autocomplete` value is used when a browser’s input-assist tries to autocomplete the form. The downside to the `autocomplete` feature is that according to [this article](https://web.dev/more-capable-form-controls/#restoring-form-state), it is not supported yet. In that case, we can use a simple implementation to restore the input to the saved value.

```tsx
formStateRestoreCallback(state, mode) {
  this.$input.value = state;
}
```

{% codepen https://codepen.io/break-stuff/pen/ZEmaEzY default-tab=js %}

## Conclusion

As you can see, this only scratches the surface of the potential of what these new APIs can do. We only implemented two attributes out of the many that are available in an `input` element and things get even more interesting when you introduce other form elements like `select`, `textarea`, and `button`. Hopefully, this gives you a solid start with creating form-associated custom elements. Happy coding!