Building Custom Form Elements


One tiny component that crops up a lot in the designs I’ve implemented lately is an auto expanding textarea. It’s been just easy enough to use this neat hack that I’ve never invested much effort in doing something a bit more robust.

Then I came across the announcement that contenteditable="plaintext-only" had broad support, and they specifically mentioned it as a solution for implementing an expanding textarea. I’ve spent quite a bit of time working with richtext editors and contenteditable that this seemed to be equal parts “really bad idea” and “possibly interesting”.

Pair this with a curiosity about why web components seem like such a great idea, but still haven’t seemed to catch on, and I had a great little learning project.

The question was, what would it take to implement a custom expanding textarea that could be dropped into any project, and what could I learn? We’ll answer that by exploring how to implement an ExpandingTextarea web component.

contenteditable

Let’s take a quick diversion shall we?

If you’ve used a rich text editor on the web, and you have, then you’ve probably used contenteditable. Try this out right now, open your browser’s devtools and add the contenteditable attribute to the body tag on this page. Now the whole page is editable. You can press cmd-b for bold, cmd-i for italics, you can even paste in html from other pages.

contenteditable="plaintext-only" ensures that only text nodes in a given element can be modified. If you think that sounds a lot like a textarea, you’d be right. There’s just one problem, a textarea’s value will show up in a form’s formdata, but a div is just a div.

A browser looking at an ExpandingTextarea component implemented in React doesn’t see an <ExpandingTextarea>, it sees a handful of divs or other element. To a user that doesn’t matter, and if you stay in react, it doesn’t matter. With the right aria attributes, assistive technologies won’t know or care about the difference either. To be clear, this is mostly fine.

It also means some semantics don’t quite work. For instance if you access the form.elements property, framework specific components won’t show up in the list. I can count the number of times I’ve done this on one hand, but it illustrates that components built in our framework of choice are semantically invisible to the browser.

One last drawback is of course that components built in one framework can’t be easily shared with other frameworks. One interesting version of this is that it also means that if you make an http request and get back HTML containing <ExpandingTextarea> you’ll need to make sure your framework handles it somehow because the browser has no clue what a <ExpandingTextarea> is.

Web Components

Browsers can define new native elements by extending existing elements like HTMLElement, and registering them with the customElements API. Once they’re registered, we have a component that behaves just like a normal element. That means they can be used with any framework or be referenced in plain html.

Additionally web components have access to some browser APIs such as the Element.attachInternals method allows you to integrate more deeply forms as we’ll see later.

Putting it all together

So now let’s piece this together. We’ll register a web component that uses contenteditable='plaintext-only', and integrate it into our forms with Element.attachInternals.

So let’s start out by defining the web component and registering it.

// Extend the native HTMLElement
export class ExpandingTextArea extends HTMLElement {
    //…
}

// Register our component
customElements.define("expanding-textarea", ExpandingTextArea);

We have a web component, but it doesn’t do anything yet. The contents of this component will be simple, just a div with contenteditable='plaintext-only':

export class ExpandingTextArea extends HTMLElement {
    // This callback is executed when the component is inserted into the document.
    connectedCallback() {
        // Create a shadow dom root to encapsulate our component's structure
        const shadow = this.attachShadow({ mode: "closed", delegatesFocus: true });

        // Attach the editable div
        const input = document.createElement('div');
        input.setAttribute('contenteditable', 'plaintext-only');
        shadow.appendChild(input);
    }
}

Try this out and you’ll have a div you can type in. That’s neat, but you could also have just put a div on the page and called it a day. Before we move on though, let’s linger on a few of the details.

Shadow DOM

It may sound mysterious or ominous, but for our purposes, the shadow DOM creates a boundary between what’s inside our component and what’s outside.

There are two interesting effects of this boundary. First, while external styles will cascade in, styles defined within the component will not spill out. Second, elements inside the component are not accessible from outside. This both means you can’t grab the internal elements with javascript, and you can’t target them with CSS.

Let’s make use of the shadow DOM’s CSS isolation, and style our component so it looks like a native textarea.

// Inside `connectedCallback`:
const style = document.createElement("style");
style.textContent = `
    :host {
        display: block;
        background: Field;
    }

    :host(:focus) {
        /* This mimics the standard behavior in Firefox */
        outline: 3px auto Highlight;
        /* This mimics the standard behavior in Chrome */
        outline: auto 3px -webkit-focus-ring-color;
    }
`;
shadow.appendChild(style);

You’ll notice :host here. This lets us target the root of our custom component, expanding-textarea in this case.

Did you also notice those weird colors? Field and Highlight? These are defined as system colors and unlike red or hotpink, they are tied to the browser’s theme. You typically won’t need these unless you want to build something that blends into the browser’s UI.

Now let’s transition from CSS to the DOM. One of the styles was :host(:focus), but how can <expanding-textarea> itself get focus? There’s an interesting option on our call to this.attachShadow, and that’s delegatesFocus. This tells the browser that when a user clicks on the component it should focus the first focusable element, and that’s our contenteditable div.

That’s enough shadow DOM to be productive.

Custom Form Elements

Right now you can type in our field, but if you put it in a form and submitted it, nothing would happen. We need to let the browser know this component should be considered a form item. To do this we signal that it is formAssociated by setting the value on the class and then attaching to the element’s internals.

export class ExpandingTextArea extends HTMLElement {
  static formAssociated = true;

  #internals: ElementInternals;
  //…
  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

If you don’t spend much time creating classes in javascript, you might not have seen the static keyword. It indicates a value pertains to the class, not the instance. In this case, if you got a reference to an <expanding-textarea> you wouldn’t see formAssociated, but if you get its constructor, ExpandingTextArea you’ll see ExpandingTextArea.formAssociated //=> true.

We only need to go one line down to see some other less common syntax. The # marks properties and methods as private. If anyone tries to access it, a TypeError is raised. You don’t need to mark things private, but it’s a nice signal to the reader.

Anyways, this.#internals = this.attachInternals() is what we’re after! This creates an instance of ElementInternals for your component. It’s a bit of a grab bag, but it enables three main things.

  • Setting the form value for a custom element
  • Enabling native form validation
  • Setting aria attributes

For our purposes, we’ll focus on setting the form value. One way to do this is to listen for changes to your component and then imperatively update the form. Here’s one way we could add that to our component:

export class ExpandingTextArea extends HTMLElement {
  #value = "";
  //…
  get value() {
    return this.#value;
  }

  set value(newValue: string) {
    this.#value = newValue
    this.#internals.setFormValue(this.#value)
  }

We’re familiar with the private property syntax, but get and set may be new if you spend most of your time in React or other frameworks. These allow you to define a getter and a setter on an object. So value looks just like a normal property to the caller, but it actually executes our getter and setter code. In this case, I’m using the getter and setter so we can intercept new values and also call setFormValue(this.#value).

Now whenever value changes the form’s FormData will also update.

We’ve skipped a bunch of details like event handling and so forth, but now you’ve seen most of the code that is unique to creating a custom form element.

Takeaways

If you take away nothing else, then know this: Browsers have made enormous progress in the past few years around web components. Many of the features we touched on weren’t broadly available until this year or last.

And just to refresh your memory, here are a few choice tidbits we stumbled across implementing ExpandingTextarea:

  • contenteditable="plaintext-only" is an interesting way to create a plaintext input
  • Web components can hook into native forms using formAssociated and attachInternals
  • Shadow DOM can encapsulate styling while hiding implementation details
  • system colors allow your elements to blend in with the browser

Now, go build something.