Form-associated custom elements

July 01, 2019 • ⏱ 5 min read

Custom elements gives us the ability to add entirely new and powerful HTML elements to the page. Things like <custom-date-picker> and <custom-multi-select> are now entirely possible. However, this also comes with a new set of problems.

The first question that comes to mind is; Can i make a custom element with a value and a name property that just gets picked up by a parent <form>? - Unfortunately, no 😔

Inputs and the shadow-root

In my previous post I explained some of the issues that can come up when using the native <button type="submit"> inside the shadow-root of custom elements. What it boils down to, in short, is that elements not in tree order will not be visible to the parent form element.

This also holds true for input elements, and as such we cannot just do the following;

import { Component, Input } from '@stencil/core';

@Component({
    tag: 'my-custom-input',
    shadow: true
})
export class MyCustomInput {

    @Input() name: string;

    render() {
        return (
            <input type="text" name="name" value={this.name}>
        )
    }
}

I´m using stencilJS, a web component compiler, in my examples

In the example the <input type="text"> element is safely tucked away inside the shadow-root. This means that a parent <form /> will not be able to pick up the input, and the name value will never show up when we submit said form.

The easiest way to fix our example is to simply not use shadow DOM at all.

Real use-cases will be probably be much more complex and the logic and encapsulation you want, might render you unable to just disable shadow DOM.

So what can we do to ensure that a parent <form> element will pick up our input? Lets look at a slightly more advanced example.

A more advanced example 🧬

Lets look at something slightly more complex than our simple input from before:

import { Component, Element, Input, Host } from '@stencil/core';

@Component({
    tag: 'custom-multi-select',
    shadow: true
})
export class CustomMultiSelect {

    @Element() el!: HtmlCustomMultiSelectElement;

    @Input() name: string;
    @Input() value?: any | null; 

    handleClick(event) {
        this.openModal(event);
    }

    render() {
        const { el, value, name } = this;

        renderInputOutsideShadowRoot(el, name, value);
        return (
            <Host
                onClick={this.handleClick}
                role="combobox"
                class="custom-select">
                <div class="custom-select-input"></div>
            </Host>
        )
    }
    ...
}

Looking at the highlighted renderInputOutsideShadowRoot above we take the container element, which is the custom element itself, the name of our custom element that we want to be part of our form, and the value.

    private renderInputOutsideShadowRoot(container: HTMLElement, name: string, value: string | null) {
        let input = container.querySelector("input.hidden-input") as HTMLInputElement | null;
        if (!input) {            input = container.ownerDocument.createElement("input");            input.type = "hidden";            input.classList.add("hidden-input");            container.appendChild(input);        }        input.name = name;
        input.value = value || "";
    }

Next we check if the container already holds a hidden input, and if not, we create a new input, set the type to hidden and append it.

The important part here, is that the input we append, is not appended inside the shadow-root of our custom element, but rather just outside of it.

In doing so, this hidden input will get picked up by the parent form and therefore participate in form submission.

The last thing we do is set the name and the value of the input to be submitted.

💡 It´s probably a good idea to serialize arrays when providing them as form values

Currently our helper function is placed within our custom element, but with the generic nature of the function, we could easily extract it out into it´s own folder and re-use it within other components. 🙌

This is a pretty elegant solution that has been used for quite some time in a lot of places already. It does add extra DOM elements as an overhead. But when dealing with rather simple forms, I’d wager the overhead is minimal.

However, there is a working draft on how to make this much, much easier for custom elements, so lets take a look at that!

Enter form-associated custom elements

Taken from chrome platform status: Form-associated custom elements enable web authors to define and create custom elements which participate in form submission.

What this means, is that we´ll be able to create our custom element and via this new API, make it directly compatible with native <form /> elements. Sounds too good to be true, right? 🤭

But it is true!

The API adds a set of lifecycle hooks to our custom elements that we can use to react to native form events such as reset and disabled status. Moreover the element will be automatically included in form submission if the element has a name attribute and value is not null.

Another plus is the fact that it works with native form validation too!

Lets take a look at our component with the new API:

import { Component, Input, Host } from '@stencil/core';

@Component({
    tag: 'custom-multi-select',
    shadow: true
})
export class CustomMultiSelect {
    static formAssociated = true;
    @Input() value?: any | null; xs

    constructor() {        super();        this.internals_ = this.attachInternals();    }
    handleClick(event) {
        this.openModal(event);
    }

    render() {
        const { el, value, name } = this;

        return (
            <Host
                onClick={this.handleClick}
                role="combobox"
                class="custom-select">
                <div class="custom-select-input"></div>
            </Host>
        )
    }
    
    private setValue(value: string) {        this.internals_.setFormValue(value);    } }

This is all it takes for our custom element to be a form-associated custom element.

Now all that we need to do to use our newly created form-associated custom element is to include it inside a parent <form> element.

<form>
    <custom-multi-select name="toppings"></custom-multi-select>
    <buttom type="submit">Send</button>
</form>

To the consumer this looks no different from using the <input type="hidden"> approach. However, it does make it a lot more intuitive to work with for custom element authors.

The extras

On top of the formAssociated static property and the attachInternals() function called in the constructor, the spec comes with a set of very nice lifecycle hooks for us to utilize:

// This is called when the 'disabled' attribute of the element or or an ancestor <fieldset> is updated
formDisabledCallback(disabled: boolean) {
    if (disabled) {
        // Set our internal disabled css class / property
    }
}

// This is called when the parent/owner form is reset
formResetCallback() {
    this.internals_.setFormValue('');
}

// This is called when the browser wants to restore user-visible state
formStateRestoreCallback(state, mode) {}

Form-associated custom elements will be enabled by default in chrome 77. No words yet from Edge or Firefox but support from Safari. In short, this is not production ready, but is a nice preview of what the API is going to look like!

I hope this gives you a good idea on how to work with forms and custom elements, good luck! 👋