This content originally appeared on Filament Group, Inc: Lab and was authored by Filament Group, Inc
Custom elements can be efficient and powerful UI building blocks, especially for large scale applications, but when it comes to building forms they need help. In this post I review what we can do now with custom elements in forms to ensure they behave as expected, and what’s on the horizon to simplify this process.
(Before we continue, I’m going to assume you’re already sold on the value of using web components and shadow DOM encapsulation, and have a working knowledge of web component standards/APIs and how to construct a basic custom element.)
The state of custom form elements in 2022
Web component standards came on the scene about 11 years ago and introduced the concept of custom elements: blank canvases that we can define, encapsulate, and reuse in ways that we can’t with plain HTML. They changed how we think about extensibility in complex, large scale sites and applications.
When it comes to form composition, though, they require that we reinvent the wheel to some extent. We need custom elements to function exactly like their HTML counterparts — with a focus state, keyboard events, and when set up with a proper <form>
and submit
button, data validation and serialization — and in ways that leverage HTML itself. That would be my ideal scenario: to seamlessly use HTML form controls in the context of web components.
Web standards and browser vendors are getting there
The ElementInternals API is meant to, essentially, apply HTML’s form capabilities to custom elements. Think: automatic form data serialization like the good old days, just drop in your form elements, specify how and where to submit, and the browser does the rest. Browser support remains incomplete (no Safari); in the meantime there’s a polyfill.
We also have the global is
attribute for extending built-in HTML form elements, but are still waiting on full support in Safari (Mac or iOS).
Until these features are fully implemented in all major browsers (ahem, Safari), it’s up to front-end developers to ensure that custom form elements work.
A checklist for building forms with custom elements
Whether you use a web component framework or library, or build your own from scratch, when using custom elements to compose forms we have to make sure they meet the standards set by HTML:
- Does the custom element sufficiently recreate HTML functionality?
- Do elements maintain
id
andname
associations? - Can you programmatically assign focus to each element?
- Do the events you care about bubble from the shadow DOM?
- Does your form submit with data?
Let’s get into it!
(Examples below can be found at this CodePen.)
1. Does the custom element sufficiently recreate HTML functionality?
When users encounter a custom form element, they expect it to act like an HTML form element, which means it should:
- gain focus via user interaction
- support mouse and key events
- maintain states, like disabled or invalid
- capture one or more values
- validate the data (when enabled), and
- submit data with the form.
One of the simplest ways to to accomplish most of the above is use an HTML element at the core of your custom element (or use a library that does, like Ing’s Lion or Shoelace).
A simple example:
class SimpleInput extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({
mode: 'open'
});
const input = document.createElement('input');
// ...add some features here...
shadow.appendChild(input);
}
}
That’s referenced in markup as:
<simple-input></simple-input>
To a user, the HTML input
rendered inside <simple-input>
behaves as expected, with a default focus state and built-in keyboard interactions. Some edits are still needed, but this method means you don’t have to start at zero, and it lowers the risk of omitting accessibility and other expected features.
To round out the functionality, I defined a couple of attributes (de facto properties) on the custom element that let us pass global and element-specific attribute values to the HTML input
:
class SimpleInput extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({
mode: 'open'
});
const input = document.createElement('input');
// set default value, if any
if (this.hasAttribute('value')) {
let val = this.getAttribute('value');
if (val) input.value = val;
}
// check for required attr
if (this.hasAttribute('required')) {
// set corresponding input property
input.required = true;
// by default, the field is invalid until filled in
this.setAttribute('invalid', true);
}
shadow.appendChild(input);
}
}
<simple-input
value="123 Main Street"
></simple-input>
<simple-input
required
></simple-input>
2. Do elements maintain id
and name
associations?
When the shadow DOM is enabled, no. Elements within don’t communicate with elements outside that shadow DOM, including the enclosing form
, label
or fieldset
, or other sibling form elements (like radio
buttons).
A fix for this is on the horizon. The Accessibility Object Model has proposed that idrefs cross shadow root boundaries (see Phase 2). It’ll be fantastic when browsers standardize on this; until then, we have to work around it if we want encapsulated, reusable, and accessible custom form elements.
Labelling elements (like <label>
or <fieldset>
) and ARIA attributes (aria-label
, aria-labelledby
, aria-describedby
) retain their built-in connections when located in the same DOM as their associated element — connections like the ability to click a label to select a checkbox
, or focus on an input
to hear the label/description read aloud by a screen reader.
For the <simple-input>
, I built in a label
that pulls its text from an attribute:
class SimpleInput extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({
mode: 'open'
});
const label = document.createElement('label');
const labelText = document.createElement('span');
labelText.classList.add('label-text');
// get text
labelText.textContent = this.getAttribute('label');
const input = document.createElement('input');
// set default value, if any
if (this.hasAttribute('value')) {
let val = this.getAttribute('value');
if (val) input.value = val;
}
// check for required attr
if (this.hasAttribute('required')) {
// set corresponding input property
input.required = true;
// by default, the field is invalid until filled in
this.setAttribute('invalid', true);
// add a label class
labelText.classList.add('required');
}
const style = document.createElement('style');
style.textContent = `
// ...styles go here
`;
// add a class to simplify styles, queries
this.classList.add('field');
label.appendChild(labelText);
label.appendChild(input);
shadow.appendChild(style);
shadow.appendChild(label);
}
}
<simple-input
label="Street Address"
value="123 Main Street"
></simple-input>
If you’re using a framework like React or Stencil, you can create a reusable functional or stateless component to inject the correct labelling markup into each custom form element; see, for example, Stencil’s Working with Functional Components.
Radio buttons coded as separate web components (and with separate shadow DOMs) won’t act as a single set, even when the child input
elements share a name
value; checking one won’t impact the others.
In a recent project we worked around this by treating the radio button set as a single element (<simple-radio-set>
), which worked well because there’s no use case for a stand-alone radio button. We configured the set’s input
s by writing template logic that looped through a passed array:
this.radio.options = [
{ label: 'Cats', value: 'cats' },
{ label: 'Dogs', value: 'dogs' },
{ label: 'Leopard Geckos', value: 'geckos', default: true }
]
3. Can you programmatically assign focus to each element?
Unlike their child HTML elements, custom form elements are not programmatically focusable, e.g., myInput.focus()
, unless you make them so. This matters, for instance, when errors are found with client-side validation and we need to move focus to the first invalid field.
Adding tabindex
to the custom element may seem like a straightforward fix for this, but it creates a few problems. It would add a second focus state to the component: one Tab click to focus the custom element, and the next Tab would focus it’s child HTML element. Disabling focus
on the child element would solve that problem, but would also disable any built-in focus-related behavior, like a screen reader announcing the associated label. You’d have to figure out how to add that back.
We can avoid all of that by enabling delegatesFocus
, which lets you programmatically set focus on the custom element. As noted in Shadow DOM v1 - Self-Contained Web Components, by Eric Bidelman, delegatesFocus: true
:
...expands the focus behavior of elements within a shadow tree. If you click a node inside shadow DOM and the node is not a focusable area, the first focusable area becomes focused. When a node inside shadow DOM gains focus,
Eric Bidelman Shadow DOM v1 - Self-Contained Web Components:focus
applies to the host in addition to the focused element.
Enable it within the attachShadow
method:
class SimpleInput extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({
mode: 'open',
delegatesFocus: true,
});
...
}
}
4. Do the events you care about bubble from the shadow DOM?
Using HTML in custom elements lets us take advantage of many built-in events and behaviors, like focus or the click/tap needed to open a select
menu, but sometimes we need the UI to react to input
or change
events, for example, when applying data formatting or switching up which question is asked next based on the user’s answer.
When a child HTML element fires a composed event (and most are composed by default), it will bubble up through the shadow DOM to its custom element parent. So, for example, if you need to format a value on input, you can do that by listening for the input event on your custom element:
class SimpleInput extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({
mode: 'open',
delegatesFocus: true,
});
...
this.addEventListener('input', formatValueFn);
shadow.appendChild(input);
}
}
For reasons outlined by the WHATWG, change
is not composed:
...events like
Domenic Denicola, WHATWG member whatwg/html: input event is composed but change isn't #5453change
,submit
, orload
are not about a 'direct user action' that affects the page as a whole. They are communicating something that changed internally to the event target in question. If that event target is inside a shadow tree, then the fact that it fired such an event is an internal detail of the component, which that component can choose to re-expose or not.
To use change
(or any other event that isn’t composed), you’ll have to manually dispatch a new event that bubbles to the custom element when the value changes:
input.addEventListener('change', (e) => {
let changeEv = new Event('change', { bubbles: true });
this.dispatchEvent(changeEv);
});
5. Does your form submit with data?
Not unless you explicitly tell it to. Custom elements need JavaScript and possibly some extra markup to:
Expose each custom element’s value for serialization
And we can do that by listening to events fired by the child elements, like input
and change
, as the user edits the form.
Earlier I added a value
attribute to <simple-input>
that sets the child input
's default value. By attaching a change
listener to the child input
, we can keep the input
's property and custom element’s attribute in sync:
input.addEventListener('change', (e) => {
// sync value with attr
this.setAttribute('value', input.value);
// dispatch change event that bubbles from the input
let changeEv = new Event('change', { bubbles: true });
this.dispatchEvent(changeEv);
});
Later this will simplify serializing the form by exposing the input
's current value outside of the shadow DOM.
This is just one example of how to capture this value for serialization. If an attribute doesn’t work for whatever reason, the same basic logic can be used to assign the value to a property on its parent element, or append it to a data object or similar. The gist is that, when it’s time to submit the form, we can efficiently grab these values without having to traverse each custom element’s shadow DOM.
Set up any component-specific client-side validation
Using HTML form elements as the basis of our components lets us take advantage of the Constraint validation API. For example, earlier I marked the address field as required
and included logic to render the same attribute on its child input
:
<simple-input
label='Street Address'
value='123 Main Street'
required
></simple-input>
We can then add methods to the component definition for altering the input
when invalid (or restored to a valid state). In this case, I’m conditionally toggling an invalid
attribute on the custom element along with error messaging and a class on the input
:
this.isInvalid = () => {
this.setAttribute('invalid', true);
errorMssg.textContent = 'Please enter a value.';
input.classList.add('invalid');
};
this.isValid = () => {
this.removeAttribute('invalid');
errorMssg.textContent = '';
input.classList.remove('invalid');
};
Then listen for the blur
event to test the input
's validity
property and call the appropriate method:
input.addEventListener('blur', (e) => {
// client-side validation
if (input.validity.valueMissing) {
this.isInvalid();
} else {
this.isValid();
}
});
Make sure your submit button submits
Our goal is to ensure form submission is handled by JavaScript in a way that mimics a <button type='submit'>
. By default, a standard HTML submit button fires two events in succession on the associated form:
submit
, which kicks off form submission and triggers the browser to run any built-in validation (e.g., contains inputs markedrequired
).formdata
, is fired when validation passes and data entries are appended to theFormData
object.
One might assume we could just bind a submit()
event to our custom button and be done with it — but no. Calling simpleForm.submit()
does not actually fire the submit
event. The submit
event only fires when called by an HTML button or submit input. However, it does go ahead and fire the formdata
event, possibly with no data attached. This is all perfectly fine.
The requestSubmit
event was created to solve this problem, and it accurately mimics button behavior when simpleForm.requestSubmit()
is called. Unfortunately, versions of Safari pre-16 don’t support it. (You can try it out now in Safari 16 beta.)
Use an HTML submit
button
Until Safari 16 is more ubiquitous, I prefer to sidestep the custom element/JavaScript event issues altogether and use HTML:
<form id='important-data'>
...custom form elements...
<button type='submit'>
</form>
Then attach a submit
listener to the form for processing client-side validation and creating a FormData
object.
If your project must use a custom button element, you can include logic in its component definition that injects a hidden <button type='submit'>
into the light DOM. You can then trigger a click on the HTML button and listen for the submit
event.
Put it all together
To wrap up the simple-input
example, the accompanying form logic might look like this (also on CodePen):
const simpleForm = document.getElementById('simple-form');
simpleForm.addEventListener('submit', (e) => {
// pause to allow validation
e.preventDefault();
let invalidFields = simpleForm.querySelectorAll('[invalid]');
if (invalidFields.length > 0) {
invalidFields.forEach((field) => {
// apply styles
field.isInvalid();
})
// focus the first invalid field
invalidFields[0].focus();
} else {
// create a FormData obj
this.simpleData = new FormData(simpleForm);
let fields = simpleForm.querySelectorAll('.field');
fields.forEach((field) => {
// add each label/value pair to the FormData obj
// the `set` method adds new or replaces existing values
// https://developer.mozilla.org/en-US/docs/Web/API/FormData/set
let name = field.getAttribute('label');
let val = field.getAttribute('value');
this.simpleData.set(name, val);
});
// at this point you can package and send your data in a few ways, more here:
// https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects
// replace the following with submission logic :)
let feedbackArr = [];
for (let [name, value] of this.simpleData) {
feedbackArr.push(`${name}: ${value}`);
}
feedback.innerHTML = `<h3>Form values submitted:</h3>` + feedbackArr.join(', ');
}
});
To recap the form logic:
- I’m double-checking validation at the form level. All required fields were assigned an
invalid
attribute by default, so if they were skipped (never triggeredblur
) or failed the internal validity check, they’ll be flagged. I also assign focus to the first invalid field so the user can correct the error and try again. - When all fields pass, we can append their values to a
FormData
object, by either creating a new object or using an existingform
's object.
Resources linked in this post
- 6 Reasons You Should Use Native Web Components, by Kai Wedekind
- Shadow DOM v1 - Self-Contained Web Components, by Eric Bidelman
- MDN:
ElementInternals
- calebdwilliams/element-internals-polyfill
- MDN:
is
attribute - Lion web component library, authored by ING
- Shoelace web component library, authored by Cory LaViska
- MDN:
:focus-within
- Can I Use Accessibility Object Model (AOM)
- A complete guide on shadow DOM and event propagation, by Pierre-Marie Dartus
input
event is composed butchange
isn’t #5453- Stencil documentation: Events
- MDN: Constraint validation API
- MDN: ValidityState
- MDN: Using FormData Objects
- MDN: HTMLFormElement.requestSubmit()
- The Anatomy of Accessible Forms: Error Messages, by Raghavendra Satish Peri
- MDN: ShadowRoot.delegatesFocus
This content originally appeared on Filament Group, Inc: Lab and was authored by Filament Group, Inc
Filament Group, Inc | Sciencx (2022-06-23T00:00:00+00:00) Building forms with custom elements. Retrieved from https://www.scien.cx/2022/06/23/building-forms-with-custom-elements/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.