DEV Community

Burton Smith
Burton Smith

Posted on • Updated on

Custom Forms with Web Components and "ElementInternals"

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. Prior to this, input elements located within a 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.

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 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.

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

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).

private $input: HTMLInputElement;
Enter fullscreen mode Exit fullscreen mode

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

connectedCallback() {
    ...
    this.$input = this.shadowRoot.querySelector('input');
}
Enter fullscreen mode Exit fullscreen mode

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.

static get observedAttributes() {
  return ["required", "value"];
}
Enter fullscreen mode Exit fullscreen mode

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.

private _attrs = {};
Enter fullscreen mode Exit fullscreen mode

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.

attributeChangedCallback(name, prev, next) {
  this._attrs[name] = next;
}
Enter fullscreen mode Exit fullscreen mode

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

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 = {};
}
Enter fullscreen mode Exit fullscreen mode

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

connectedCallback() {
  ...
  this.$input = shadowRoot.querySelector('input');
  this.setProps();
}
Enter fullscreen mode Exit fullscreen mode

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.

attributeChangedCallback(name, prev, next) {
  this._attrs[name] = next;
  this.setProps();
}
Enter fullscreen mode Exit fullscreen mode

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

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.

static formAssociated = true;
private _internals: ElementInternals;

constructor() {
  super();
  this._internals = this.attachInternals();
}
Enter fullscreen mode Exit fullscreen mode

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.

connectedCallback() {
  ...
  shadowRoot.innerHTML = `<input type="text" />`;
}
Enter fullscreen mode Exit fullscreen mode

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.

<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input"></my-input>
</form>
Enter fullscreen mode Exit fullscreen mode

NOTE: There is an accessibility bug in Safari and NVDA 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.

const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true });
Enter fullscreen mode Exit fullscreen mode

“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.

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

setValidity(flags: ValidityStateFlags, message?: string, anchor?: HTMLElement)
Enter fullscreen mode Exit fullscreen mode

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.

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

connectedCallback() {
  ...
  this.$input = shadowRoot.querySelector('input');
  this._internals.setValidity(this.$input.validity, 
  this.$input.validationMessage, this.$input);
}
Enter fullscreen mode Exit fullscreen mode

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.

this._internals.setValidity(
  {
    valueMissing: true
  }, 
  'Please fill out this required field', 
  this.$input
);
Enter fullscreen mode Exit fullscreen mode

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.

<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input" required></my-input>
  <button>Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode

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.

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

private handleInput() {
  this._internals.setValidity(this.$input.validity, this.$input.validationMessage, this.$input);
}
Enter fullscreen mode Exit fullscreen mode

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.

connectedCallback() {
  ...
  this._internals.setFormValue(this.value);
}
Enter fullscreen mode Exit fullscreen mode

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

private handleInput() {
  ...
  this._internals.setFormValue(this.value);
}
Enter fullscreen mode Exit fullscreen mode

Testing Form Data

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

<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input" name="myInput" required></my-input>
  <button>Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode

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.

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')}'`);
});
Enter fullscreen mode Exit fullscreen mode

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

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.

formDisabledCallback(disabled: boolean) {
  this.$input.disabled = disabled;
}
Enter fullscreen mode Exit fullscreen mode

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.

private _defaultValue = "";

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

formResetCallback() {
  this.$input.value = this._defaultValue;
}
Enter fullscreen mode Exit fullscreen mode

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.

<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> 
Enter fullscreen mode Exit fullscreen mode

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, it is not supported yet. In that case, we can use a simple implementation to restore the input to the saved value.

formStateRestoreCallback(state, mode) {
  this.$input.value = state;
}
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (4)

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

Good summary.

Note you can change

const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = ...
Enter fullscreen mode Exit fullscreen mode

to

this.attachShadow({ mode: 'open' }).innerHTML = ...
Enter fullscreen mode Exit fullscreen mode

Because attachShadow SETS and RETURNS this.shadowRoot

And there is a element.toggleAttribute("required" , state ) method to further condense your code

Collapse
 
stuffbreaker profile image
Burton Smith

Very cool! Updated, thanks!

Collapse
 
abhishekmpatel profile image
Abhishek

I am new to using we-components. Can anyone explain me what is piece of code is doing different apart from the usual form element and submit actions. I see that now the fields are controlled, but on the other hand there's is a lot of code involved and ultimately, we are achieving the same result as if we could have used just input element and handle the edge cases in Js. The crux is that, by writing more JavaScript, what advantage are we getting here ?

Collapse
 
stuffbreaker profile image
Burton Smith • Edited

Great question! A simple answer is that one of the cool things but also painful things about using the shadow DOM is that it exists outside the flow of the rest of the DOM. So, when you have things like input elements in the shadow DOM, other elements like labels and form elements can't see or interact with those input elements. This helps web components behave like native input elements.