Building Form-Associated Components in Stencil
As of v4.5.0, Stencil has support for form-associated custom elements. This allows Stencil components to participate in a rich way in HTML forms, integrating with native browser features for validation and accessibility while maintaining encapsulation and control over their styling and presentation.
Browser support for the APIs that this feature depends on is still not universal1 and the Stencil team has no plans at present to support or incorporate any polyfills for the browser functionality. Before you ship form-associated Stencil components make sure that the browsers you need to support have shipped the necessary APIs.
Creating a Form-Associated Component
A form-associated Stencil component is one which sets the new formAssociated
option in the argument to the @Component
decorator to true
, like so:
import { Component } from '@stencil/core';
@Component({
tag: 'my-face',
formAssociated: true,
})
export class MyFACE {
}
This element will now be marked as a form-associated custom element via the
formAssociated
static property, but by itself this is not terribly useful.
In order to meaningfully interact with a <form>
element that is an ancestor
of our custom element we'll need to get access to an
ElementInternals
object corresponding to our element instance. Stencil provides a decorator,
@AttachInternals
, which does just this, allowing you to decorate a property on
your component and bind an ElementInternals
object to that property which you
can then use to interact with the surrounding form.
Under the hood the AttachInternals
decorator makes use of the very similarly
named
attachInternals
method on HTMLElement
to associate your Stencil component with an ancestor
<form>
element. During compilation, Stencil will generate code that calls
this method at an appropriate point in the component lifecycle for both
lazy and custom
elements builds.
A Stencil component using this API to implement a custom text input could look like this:
import { Component, h, AttachInternals, State } from '@stencil/core';
@Component({
tag: 'custom-text-input',
shadow: true,
formAssociated: true
})
export class CustomTextInput {
@State() value: string;
@AttachInternals() internals: ElementInternals;
handleChange(event) {
this.value = event.target.value;
this.internals.setFormValue(event.target.value);
}
componentWillLoad() {
this.internals.setFormValue("a default value");
}
render() {
return (
<input
type="text"
value={this.value}
onInput={(event) => this.handleChange(event)}
/>
)
}
}
If this component is rendered within a <form>
element like so:
<form>
<custom-text-input name="my-custom-input"></custom-text-input>
</form>
then it will automatically be linked up to the surrounding form. The
ElementInternals
object found at this.internals
will have a bunch of
methods on it for interacting with that form and getting key information out of
it.
In our <custom-text-input>
example above we use the
setFormValue
method to set a value in the surrounding form. This will read the name
attribute off of the element and use it when setting the value, so the value
typed by a user into the input
will added to the form under the
"my-custom-input"
name.
This example just scratches the surface, and a great deal more is possible with
the ElementInternals
API, including setting the element's
validity,
reading the validity state of the form, reading other form values, and more.
Lifecycle Callbacks
Stencil allows developers building form-associated custom elements to define a
standard series of lifecycle
callbacks
which enable their components to react dynamically to events in their
lifecycle. These could allow fetching data when a form loads, changing styles
when a form's disabled
state is toggled, resetting form data cleanly, and more.
formAssociatedCallback
This callback is called when the browser both associates the element with and
disassociates the element from a form element. The function is called with the
form element as an argument. This could be used to set an ariaLabel
when the
form is ready to use, like so:
import { Component, h, AttachInternals } from '@stencil/core';
@Component({
tag: 'form-associated',
formAssociated: true,
})
export class FormAssociatedCmp {
@AttachInternals()
internals: ElementInternals;
formAssociatedCallback(form) {
form.ariaLabel = 'formAssociated called';
}
render() {
return <input type="text" />;
}
}
formDisabledCallback
This is called whenever the disabled
state on the element changes. This
could be used to keep a CSS class in sync with the disabled state, like so:
import { Component, h, State } from '@stencil/core';
@Component({
tag: 'form-disabled-cb',
formAssociated: true,
})
export class MyComponent {
@State() cssClass: string = "";
formDisabledCallback(disabled: boolean) {
if (disabled) {
this.cssClass = "background-mode";
} else {
this.cssClass = "";
}
}
render() {
return <input type="text" class={this.cssClass}></input>
}
}
formResetCallback
This is called when the form is reset, and should be used to reset the form-associated component's internal state and validation. For example, you could do something like the following:
import { Component, h, AttachInternals } from '@stencil/core';
@Component({
tag: 'form-reset-cb',
formAssociated: true,
})
export class MyComponent {
@AttachInternals()
internals: ElementInternals;
formResetCallback() {
this.internals.setValidity({});
this.internals.setFormValue("");
}
render() {
return <input type="text"></input>
}
}
formStateRestoreCallback
This method will be called in the event that the browser automatically fills out your form element, an event that could take place in two different scenarios. The first is that the browser can restore the state of an element after navigating or restarting, and the second is that an input was made using a form auto-filling feature.
In either case, in order to correctly reset itself your form-associated component will need the previously selected value, but other state may also be necessary. For instance, the form value to be submitted for a date picker component would be a specific date, but in order to correctly restore the component's visual state it might also be necessary to know whether the picker should display a week or month view.
The
setFormValue
method on ElementInternals
provides some support for this use-case, allowing
you to submit both a value and a state, where the state is not added to
the form data sent to the server but could be used for storing some
client-specific state. For instance, a pseudocode sketch of a date picker
component that correctly restores whether the 'week' or 'month' view is active
could look like:
import { Component, h, State, AttachInternals } from '@stencil/core';
@Component({
tag: 'fa-date-picker',
formAssociated: true,
})
export class MyDatePicker {
@State() value: string = "";
@State() view: "weeks" | "months" = "weeks";
@AttachInternals()
internals: ElementInternals;
onInputChange(e) {
e.preventDefault();
const date = e.target.value;
this.setValue(date);
}
setValue(date: string) {
// second 'state' parameter is used to store both
// the input value (`date`) _and_ the current view
this.internals.setFormValue(date, `${date}#${this.view}`);
}
formStateRestoreCallback(state, _mode) {
const [date, view] = state.split("#");
this.view = view;
this.setValue(date);
}
render() {
return <div>
Mock Date Picker, mode: {this.view}
<input class="date-picker" onChange={e => this.onInputChange(e)}></input>
</div>
}
}
Note that the formStateRestoreCallback
also receives a second argument,
mode
, which can be either "restore"
or "autocomplete"
, indicating the
reason for the form restoration.
For more on form restoration, including a complete example, check out this great blog post on the subject.
Resources
- WHATWG specification for form-associated custom elements
- ElementInternals and Form-Associated Custom Elements from the WebKit blog
- Web.dev post detailing how form-associated lifecycle callbacks work
- See https://caniuse.com/?search=attachInternals for up-to-date adoption estimates.↩