Building forms with custom elements

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

Permalink to '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

Permalink to '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

Permalink to '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:

  1. Does the custom element sufficiently recreate HTML functionality?
  2. Do elements maintain id and name associations?
  3. Can you programmatically assign focus to each element?
  4. Do the events you care about bubble from the shadow DOM?
  5. 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?

Permalink to '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?

Permalink to '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 inputs 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?

Permalink to '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, :focus applies to the host in addition to the focused element.

Eric Bidelman
Shadow DOM v1 - Self-Contained Web Components

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?

Permalink to '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 change, submit, or load 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.

Domenic Denicola, WHATWG member
whatwg/html: input event is composed but change isn't #5453

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?

Permalink to '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

Permalink 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

Permalink to '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

Permalink to '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:

  1. submit, which kicks off form submission and triggers the browser to run any built-in validation (e.g., contains inputs marked required).
  2. formdata, is fired when validation passes and data entries are appended to the FormData 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

Permalink to '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 triggered blur) 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 existing form’s object.

Resources linked in this post

Permalink to 'Resources linked in this post'

All blog posts