Qubyte Codes

My first custom element

Published

After some years of browser vendors working out what web components should look like, they're almost ready for the prime time. The part which I find most intriguing (custom elements) has finally stabilised. With custom elements, you can make new HTML elements which have custom behaviour which you define using JavaScript. In this post I'll demonstrate a custom element for fuzzy counting.

Custom elements are created in two parts. Firstly we need to extend an element with a JavaScript class.

class FuzzyCount extends HTMLElement {

}

So far this describes no custom behaviour. All this is is the extended class, with identical behaviour to an HTMLElement. We can customize the constructor to add the behaviour we want. In order to give the element data to use, it must be passed in as an attribute. We'll use an attribute called count.

class FuzzyCount extends HTMLElement {
  constructor() {
    // The parent constructor must be called
    // before using `this`.
    super();

    // rawCount is a string.
    const rawCount = this.getAttribute('count');
    const count = parseInt(rawCount, 10);

    // Set text content based on the count.
    if (count === 0) {
      this.textContent = 'none';
    } else if (count === 1) {
      this.textContent = 'one';
    } else if (count === 2) {
      this.textContent = 'a couple';
    } else if (count < 5) {
      this.textContent = 'a few';
    } else if (count < 10) {
      this.textContent = 'several';
    } else {
      this.textContent = 'lots';
    }
  }
}

The element reads and parses the count attribute, giving itself a text content accordingly.

To make the element available to a page, it must be registered. One interesting restriction placed upon custom elements is that they must contain a hyphen so that they can be distinguished from built-in elements. We're going to register our element as fuzzy-count, but in the real world you should prefix it with a namespace. For example, if we're working at a place called Funky Corp, we could name the element funkycorp-fuzzy-count.

customElements.define('fuzzy-count', FuzzyCount);

Now we can use this to make elements on the page!

<fuzzy-count count="3"></fuzzy-count>

Any matching custom elements which exist before the element is defined will be upgraded. This means a page can be sent by the server with custom elements included, and everything will be rendered properly once the custom element is registered.

But what if we want to create elements in JS? We can try:

const fuzzyCount = document.createElement('fuzzy-count');

but an error will be thrown. It turns out that newly constructed elements cannot contain other stuff (the text content). Even if no error was thrown, we'd have an element with no count attribute, and so we've missed our chance since all the logic is in the constructor. It turns out that the constructor is the wrong place for this stuff.

Thankfully we can make a few changes to defer the setting of text content.

class FuzzyCount extends HTMLElement {
  // Called when the element is inserted into
  // the document or upgraded.
  connectedCallback() {
    this.setTextContent();
  }

  setTextContent() {
    const rawCount = this.getAttribute('count');

    if (!rawCount) {
      this.textContent = '';
      return;
    }

    const count = parseInt(rawCount, 10);

    if (count === 0) {
      this.textContent = 'none';
    } else if (count === 1) {
      this.textContent = 'one';
    } else if (count === 2) {
      this.textContent = 'a couple';
    } else if (count < 5) {
      this.textContent = 'a few';
    } else if (count < 10) {
      this.textContent = 'several';
    } else {
      this.textContent = 'lots';
    }
  }
}

The logic which sets the textContent has moved to its own method setTextContent, and connectedCallback calls it when the element is "connected" to avoid the error when constructing the element in JS. Connected is called when the element is inserted into the document, or the element is upgraded by custom element registration.

This is enough to get the behaviour we want, so long as the count attribute of a new fuzzy-count element is set before it is appended to the document.

const fuzzy = document.createElement('fuzzy-count');
fuzzy.setAttribute('count', 10);
document.body.appendChild(fuzzy);

We can take one more step to make the element react to changes to the count attribute...

class FuzzyCount extends HTMLElement {
  // Called for each watched attribute when
  // the element is added to the document or
  // upgraded. Also fired for a watched
  // attribute when it is added, updated, or
  // removed.
  attributeChangedCallback() {
    this.setTextContent();
  }

  // A static getter because we can't add a
  // static value within a class declaration
  // directly (yet).
  static get observedAttributes() {
    return ['count'];
  }

  setTextContent() {
    // Same as before.
  }
}

connectedCallback has been replaced with attributeChangedCallback, and a static getter defines the list of attributes to watch. For each watched attribute, when an element is inserted, upgraded, or the attribute is added, updated, or removed, this attributeChangedCallback is called. These changes allow us to update elements before appending as before, and also after appending.

const fuzzy = document.createElement('fuzzy-count');
document.body.appendChild(fuzzy);
fuzzy.setAttribute('count', 10);

Overall I'm impressed. I've only covered some of the API available here. It is a little fiddly, but upon consideration it all seems to make sense so far. It strikes me that a good way to use it would be for small components, or as a low level primitive for a front end framework.

Finally, a note on compatibility. At the time of writing Chrome and Safari support custom elements with no additional effort. Other browsers must be polyfilled for support.