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>`;
}
});
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;
In our connectedCallback
method, we can query the shadow DOM contents for our input element.
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.
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.
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.
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.
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.
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.
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.
Associating Custom Elements with Forms
Associating your custom element is surprisingly straightforward and can be done in 2 steps:
set the
formAssociated
static property totrue
Expose the
ElementInternals
API by callingthis._internals = this.attachInternals();
in the component’s constructor.
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.
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.
<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 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 });
“If
delegatesFocus
istrue
, 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;
}
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)
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;
}
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);
}
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
);
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>
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);
}
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);
}
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);
}
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>
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')}'`);
});
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;
}
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;
}
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>
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;
}
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)
Good summary.
Note you can change
to
Because
attachShadow
SETS and RETURNSthis.shadowRoot
And there is a
element.toggleAttribute("required" , state )
method to further condense your codeVery cool! Updated, thanks!
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 ?
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.