Why Web Components Work For Me

by Tim Case

It’s monday morning and waiting for you is an email.

Hello web dev genius,

We received a support ticket from a deaf customer who is having trouble filling in the order form with a screen reader. We need you to review all forms and make sure they conform to WCAG 2.0 Level AA. This needs to be done across all the enterprise web properties: The React consumer facing app, the RubyonRails B2B app, the Wordpress blog, and the Shopify store. Also come to think of it while you're there can you also update the forms to use the new brand colors we just received from Italy?

After adopting web components to solve a specific technical hurdle I found them so useful that they quickly became my new favorite browser standard in recent years. Web components are the basic building block of my frontend design system for WingTask, and I’m really happy with the final results I’m getting in terms of the development process and the finished product. There’s multiple reasons why web components work well for me and since they don’t seem to be so popular I’d like to try to explain why I think they should be given wider consideration.

  1. Portability.

    Write once, use everywhere. Web components are a browser standard that can be used anywhere that HTML is supported. In practice this means being able to create a single web component that will work across different JS frameworks, backend frameworks, templating languages and anything else that renders to the browser.

  2. Consistency.

    A single component can be worked on in isolation so that many details are captured in one place. Following it’s completion, the web component can be used by other staff as an easy to apply HTML tag. The web component encapsulates functionality that would be hard to replicate precisely multiple times across an app by hand. It makes consistently enforcing brank look and feel as well as writing accessible HTML achievable.

  3. Composition.

    Web components can be composed of smaller more discrete web components which is cool because the lowest level web components become basic building blocks that are mixed and matched to produce more complex web components and this sequential process conveys a feeling of maneagable complexity rather than incomprehesible overwhelm.

Returning to our assigned task of making forms accessible across an enterprise, let’s focus on a single text input for forms.

How much work does it take to build a text input for an HTML form?

Not very hard in the naive approach.

<input type="text" name="fname" placeholder="First name" />

This is the simplest possible input that could possibly work, but is it good enough? This type of simple input probably powers most of the forms on the web, and while it minimally functions it can be improved to provide a better experience for the user. Placeholders for input fields are popular because they economize space and are easy to implement however using a placeholder attribute is known to have a lot of issues and should be avoided.

<label for="fname">First Name</label>
<input type="text" id="fname" name="fname" />

Replacing the placeholder with a label is a much better way to identify an input, but notice now the label is coupled to the input. In order for a label to work properly it must have it’s “for” attribute assigned with the “id” attribute of the input it refers to. This little coupling detail is sometimes hard for a dev to remember. My guess is that there are a large number of forms that don’t properly label their inputs because not having a “for” attribute doesn’t break the form. It will still submit it’s data properly but the form is not as valid as it could be.

Moving on past the label, we need to indicate to the user that “First Name” is required, so let’s add some helper text.

<label for="fname">
  First Name
</label>
<p id="first-name-helper">
  required
</p>
<input type="text" id="fname" name="fname"
    aria-describedby="first-name-helper"/>

Okay, so now we have two elements besides the input and they are both coupled to the input at two different levels of direction. The input’s id is used by the label and the id for the p tag of the helper text is used by the input’s “aria-describedby”. The use of “aria-describedby” will help a screenreader to associate the helper text with the input.

Showing validation errors for an input is quite common so let’s add that.

<label for="fname">
  First Name
</label>
<p id="first-name-helper">
  required
</p>
<p id="first-name-error-message" class="form-error-message">
  Enter a first name.
</p>
<input type="text" id="fname" name="fname"
aria-describedby="first-name-helper first-name-error-message"
class="form-input form-input-error" aria-invalid="true" />

Did you know an “aria-describedby” can take multiple ids separated by a space?

This will cause a screenreader to announce three things when the input has focus:

  1. “First Name”
  2. “required”
  3. “Enter a first name”

In summary here is a list of rules for the input:

  1. An input must have a “form-input” class for it’s visual appearance.
  2. An input must have a corresponding label.
  3. The label must have it’s “for” attribute assigned to the “id” of the input.
  4. Helper text may optionally be associated with the input.
  5. The input must have an “aria-describedby” with an id of the helper text element.
  6. Error message may optionally be associated with the input.
  7. An input with an error will also have a “form-input-error” class where multiple classes are separated by a space.
  8. The “aria-describedby” of the input will contain the id of the error message element. In the case that an “aria-describedby” is already added for helper text, multiple ids for the “aria-describedby” can be separated by a space.
  9. An input with an error will have an “aria-invalid” assigned with “true”

I don’t know about you but if I’m going to be dealing with this much complexity I only want to have to go through this much pain one time and one time only.

Supposing we built a web component that encapsulated all these rules here’s what it might look like being used on a form for three different fields.

<form>
  <acme-text-input
    label="First Name"
    id="first-name"
    helper-text="required"
    value="Joe"
  ></acme-text-input>
  <acme-text-input
    label="Last Name"
    id="last-name"
    helper-text="required"
    error-message="Enter a last name."
  ></acme-text-input>
  <acme-text-input label="Phone" id="phone"></acme-text-input>
</form>

Here is a possible implementation.

class AcmeTextInput extends HTMLElement {
  connectedCallback() {
    this.innerHTML = Mustache.render(this.template, {
      name: this.id,
      id: this.id,
      label: this.label,
      helperText: this.helperText,
      helperId: this.helperId,
      ariaDescribedBy: this.ariaDescribedBy,
      value: this.value,
      errorMessage: this.errorMessage,
      errorMessageId: this.errorMessageId,
      className: this.className,
    });
  }

  get template() {
    return document.getElementById("template").innerHTML;
  }

  get id() {
    return this.getAttribute("id");
  }

  get className() {
    let a = ["form-input"];
    a = this.hasAttribute("error-message") ? [...a, "form-input-error"] : a;

    return a.join(" ");
  }

  get label() {
    return this.getAttribute("label");
  }

  get value() {
    return this.getAttribute("value");
  }

  get helperText() {
    return this.getAttribute("helper-text");
  }

  get helperId() {
    return [this.id, "helper-text"].join("-");
  }

  get errorMessage() {
    return this.getAttribute("error-message");
  }

  get errorMessageId() {
    return [this.id, "error-message"].join("-");
  }

  get ariaDescribedBy() {
    let a = [];
    a = this.hasAttribute("helper-text") ? [...a, this.helperId] : a;
    a = this.hasAttribute("error-message") ? [...a, this.errorMessageId] : a;

    return a.length > 0 ? a.join(" ") : null;
  }
}
customElements.define("acme-text-input", AcmeTextInput);

I’m using mustache for templating, this is what the mustache template would look like.

<div class="acme-text-input">
  <label for='{{id}}'>{{label}}</label>

  {{#helperText}}
    <p id="{{helperId}}" class="form-helper-text">{{helperText}}</p>
  {{/helperText}}
  {{#errorMessage}}
    <p id="{{errorMessageId}}" class="form-error-message">
      {{errorMessage}}
    </p>
  {{/errorMessage}}
  <input
    name='{{name}}'
    id="{{id}}"
    class='{{className}}'
    {{#value}}value='{{value}}'{{/value}}
    {{#ariaDescribedBy}}
    aria-describedby='{{ariaDescribedBy}}'
    {{/ariaDescribedBy}}
  />
</div>

This is how the DOM looks after the web component is rendered.

<form>
    <acme-text-input label="First Name" id="first-name" helper-text="required" value="Joe">
      <div class="acme-text-input">
        <label for="first-name">First Name</label>

        <p id="first-name-helper-text" class="form-helper-text">required</p>

        <input name="first-name" id="first-name" class="form-input" value="Joe" aria-describedby="first-name-helper-text">
      </div>
    </acme-text-input>
    <acme-text-input label="Last Name" id="last-name" helper-text="required" error-message="Enter a last name.">
      <div class="acme-text-input">
        <label for="last-name">Last Name</label>

        <p id="last-name-helper-text" class="form-helper-text">required</p>
        <p id="last-name-error-message" class="form-error-message">Enter a last name.</p>
        <input name="last-name" id="last-name" class="form-input form-input-error" aria-describedby="last-name-helper-text last-name-error-message">
      </div>
    </acme-text-input>
        <acme-text-input label="Phone" id="phone">
      <div class="acme-text-input">
        <label for="phone">Phone</label>
        <input name="phone" id="phone" class="form-input">
      </div>
    </acme-text-input>
</form>

Here is all the above together in a codepen.

See the Pen Input web component by Tim Case (@timcase) on CodePen.

Conclusion

A form is composed of web components each handling the complexities of an input in a consistent manner. Apply the web components across all forms in an app and you get consistency and a single point of change should you want to change those components.

Now go beyond the app and you can use the same web components for a wordpress blog, or a shopify store, so the web components have portability wherever something is being rendered in the browser.

Finally, web components can be composed of other web components which supports a russian doll approach to building components from smaller components such as that advocated with Atomic Design. Composition makes for a component system where changes can be done in discrete locations.

This is why Web components are the fundamental building block of my frontend design system.

This last in a series of articles describing how I build user interfaces for WingTask, the previous articles in this series are:

How I Build User Interfaces For WingTask

How I Sketch Interfaces on Pad and Paper Storybook is an Essential Tool

Why I’m Having so Much Fun with TailwindCSS