Custom element base class

Introduction

This base class should be familiar at this point — we've seen bits and pieces of it as we've worked through the text. It's a combination of various superclasses that I've used at NPR and in personal projects, and is intended to smooth off the rough parts of the custom element API: making it easier to declaratively bind methods to an instance, inject templating, and define an element.

customElement.js

class CustomElement extends HTMLElement {

  constructor() {
    super();
    // new.target is the current constructor function
    var def = new.target;

    // if a shadow template is defined, inject it and find marked elements
    this.shadowElements = {};
    if (def.shadowTemplate) {
      this.attachShadow({ mode: "open" });
      this.shadowRoot.innerHTML = def.shadowTemplate;
      this.shadowRoot.querySelectorAll(`[data-as]`).forEach(el => {
        var name = el.dataset.as;
        this.shadowElements[name] = el;
      });
    }

    // bind methods for events to the current element
    if (def.boundMethods) {
      def.boundMethods.forEach(f => this[f] = this[f].bind(this));
    }

    // these properties will update their attributes
    if (def.mirroredProps) {
      def.mirroredProps.forEach(p => Object.defineProperty(this, p, {
        get() { this.getAttribute(p) },
        set(v) { this.setAttribute(p, v); return v; }
      }));
    }
  }

  // send an event up the tree
  dispatch(event, detail) {
    var e = new CustomEvent(event, {
      bubbles: true,
      composed: true,
      detail
    });
    this.dispatchEvent(e);
  }

  // looks for a static template getter on the class
  // injects that HTML into the element's light DOM
  // returns a hash of "data-as" elements
  // this is memoized and will only "run" once
  illuminate() {
    // get the light DOM template
    var template = this.constructor.lightTemplate;
    // inject into the node and query for marked elements
    this.innerHTML = template;
    var manuscript = {};
    var landmarks = this.querySelectorAll("[data-as]");
    for (var l of landmarks) {
      var key = l.dataset.as;
      manuscript[key] = l;
    }
    // replace this method with a memoized version
    this.illuminate = () => manuscript;
    // return the elements lookup
    return manuscript;
  }

  // handle registration
  static define(tag) {
    try {
      window.customElements.define(tag, this);
    } catch (err) {
      console.log(`Couldn't (re)define ${tag} element`);
    }
  }
}

Notes

The constructor

As mentioned in earlier chapters, this constructor performs a few tasks via introspection on the class definition itself:

dispatch()

Basically just a wrapper for dispatchEvent() that makes it easier to create and broadcast an event up the tree in a single step. Note that events are marked as composed, so that they cross shadow DOM boundaries.

illuminate()

In the 2020 primary results rig at NPR, we didn't use the shadow DOM. Our tooling was not set up to handle it, I didn't yet know how to use slots effectively to style content in the light DOM tree, and I didn't want to confuse my team.

However, this meant we were challenged in how to handle templating consistently: remember, a custom element is not allowed to modify its inner or outer HTML during the constructor. Overwriting the contents indiscriminately would mean we'd need a way to keep event listeners or input state from being wiped out. And I was trying to keep bundle size down by not bringing in a template library (this was, in retrospect, premature optimization).

The illuminate() method is a clever way around this self-imposed problem. It's a lazy-evaluated templating engine: the first time it's called, it sets the innerHTML of the component from a static class property, and creates a lookup of elements marked with "data-as" attributes. Then it replaces itself with a simple function that just returns the markup. As a result, you can call this.illuminate() as many times as you want, from any lifecycle method, in any order, and it will only actually alter the element contents the first time.

Typical usage of illuminate() would go something like this:

attributeChangedCallback(attr, was, value) {
  // get the cached element references
  var { label, link } = this.illuminate();
  switch (attr) {
    case "src":
      link.href = value;
    break;

    case "headline":
      label.innerHTML = value ? value.trim() : "";
    break;
  }
}

Looking back on it, we would have been much better off using a micro-template engine to do JSX-style rendering. We were already loading a simple EJS template library, doT, and we could have unified the static and dynamic DOM portions together. However, hindsight is 20/20 — and frankly, illuminate() is just such a clever little puzzle-box that I couldn't bear to kill my darlings.