Using DOMTokenList

DOMTokenList is a built-in DOM interface that represents a set of string tokens. If you are familiar with classList, you are already familiar with it. In a recent project, I wanted to make a custom element property as a DOMTokenList, here’s what I learned.

Why use DOMTokenList?

Take the class attribute as an example, its value is a list of space-separated strings. If you want to add new classes, it’s relatively easy, but don’t forget to check if the new class already exists. And if you were to remove a class or replace one with another, it’ll involve more string manipulation or even regular expressions. But with the classList property, which is a reflection of the class attribute’s value, it’s much easier to change the class list, you can use methods like add(), remove(), replace(), toggle(), etc, you can also access individual classes with item() method or the bracket notation (e.g., classList[0]). In summary, a DOMTokenList takes a list of space-separated strings and makes it structural, and any updates on the structure will be reflected in the original list of strings.

intl-elements is the project I’ve been working on, I won’t go into details of the project, that’ll be articles for other days. But in short, it’s a collection of custom elements that makes it easy to use the Intl API declaratively. One of the key features of this library is for component users to pass in a list of locales to the custom elements, so that they can use these locales with Intl APIs to format strings. The attribute to pass in the locale list is locales, and I thought it would be nice to create a localeList property as a DOMTokenList so that authors can easily change the locale list, just like how class attribute and classList property work.

Implementing DOMTokenList

If you type “DOMTokenList” in a browser developer console, you can see the interface is exposed in the global context. But if you try to call it or create an instance with the new operator, browsers will throw TypeError: Illegal constructor. This is similar to the CustomElementRegistry interface. Because both are interfaces, not classes, you can’t instantiate them. However, browsers exposes window.customElements as an implementation of the CustomElementRegistry interface, so that developers can use it directly, e.g. customElements.define(). For DOMTokenList, there’s no such existing implementation in the global context. So I thought I could just implement one myself since it doesn’t seem to be very complicated. If you are interested in the implementation, here is the source code.

For the most part, the implementation was straightforward, I created a private property #list to store the actual locale list as an Array, and did some string parsing and manipulation to implement methods like add(), remove(), replace(), toggle(), entries(), item(), values(), etc.

To inform the custom elements that the list has been updated, I added a method, onChange(), so custom elements can call it and pass in callback functions, similar to how addEventListener() works. Any list-changing methods will execute all the callback functions so that the custom elements can update their internal states.

So far it’s all good, until I was stuck on how to allow accessing list items via the bracket notation, e.g. myElement.localeList[1]. I ended up looping through the private property, #list, and adding indexed getters (accessing list items is read-only):

this.#list.forEach((el, i) => {
  Object.defineProperty(this, i, {
    configurable: true,
    get: () => el,
    set: () => {},
  });
});

And inside list changing methods like add(), I had to first clear out all these getters and set them again, the clearing looks like this:

this.#list.forEach((_, i) => {
  Object.defineProperty(this, i, {
    get: () => undefined,
  });
});

This implementation worked, but it didn’t feel right. It felt like over-engineered and fragile, and when I was trying to print out an element’s localeList in a browser developer console, the output looked messy, not as clean as a classList would. That’s when I found this StackOverflow answer, suggesting to just using a classList.

Using classList

On the high level, the idea is to create an HTML element without connecting it to document, and use its classList as the localeList.

First, I created a function for creating the localeList in custom elements:

function createLocaleList(initialValue, onChange) {
  const hostingElement = document.createElement('div');
  const localeList = hostingElement.classList;
  localeList.value = initialValue ?? '';

  let observer;
  if (typeof onChange === 'function') {
    observer = new MutationObserver(() => {
      onChange();
    });
    observer.observe(hostingElement, {
      attributes: true,
      attributeFilter: ['class'],
    });
  }

  return localeList;
}

In a custom element definition, I could call this function like so:

class MyElement extends HTMLElement {
  // ...

  #localeList;

  get localeList() {
    return this.#localeList;
  }

  connectedCallback() {
    this.#localeList = createLocaleList(
      this.getAttribute('locales');
      () => {
        this.setAttribute(this.#localeList.value);
        // And maybe re-render the element’s content
        // based on the new locales.
      }
    );
  }

  // ...
}

Since I created a MutationObserver during the localeList creation, it would be a good idea to disconnect the observer when the custom element is disconnected (removed) from the DOM. So I added a __destroy__() method, the underlines are just to mark the method as special since it’s only intended to be used inside the custom elements, not by custom element users.

function createLocaleList(initialValue, onChange) {
  // ...

  Object.defineProperty(localeList, '__destroy__', {
    value: function() {
      observer?.disconnect();
    },
  });

  // ...
}

Then I can call it in the custom element’s disconnectedCallback():

class MyElement extends HTMLElement {
  // ...

  disconnectedCallback() {
    this.#localeList.__destroy__();
  }

  // ...
}

Last but not least, since the items of a locale list should be valid language tags, I should implement DOMTokenList’s supports() method. A localeList belongs to a custom element, which is designed for a particular Intl API, and each Intl API has a supportedLocalesOf() method to check support. Hence, I need to know which Intl API when creating the locale list.

function createLocaleList(intl, initialValue, onChange) {
  // ...

  Object.defineProperties({
    'supports': {
      value: function(locale) {
        if ('supportedLocalesOf' in intl) {
          return intl.supportedLocalesOf(locale).length > 0;
        }
        return true;
      }
    },
    '__destroy__': {/* ... */},
  });

  // ...
}

Now custom element users can check if a locale is supported by the custom element’s internal Intl object:

if (myElement.localeList.supports('es-JP')) {
  myElement.localeList.add('es-JP');
} else {
  myElement.localeList.add('es');
}

Note that I could also use other DOMTokenList, like part. But classList has much better cross-browser support. Also, unlike relList, it doesn’t define the supports() method, so I don’t have to override any built-in functionality to add supports() to localeList.

If you are interested in the new implementation, here is the source code.

Conclusion

I think DOMTokenList is a very useful interface, especially for custom elements. When developers use our custom elements, they should feel familiar with the APIs like writing HTML code, and making some custom element properties as DOMTokenList would help with that. I wish we could get a better way to create DOMTokenLists in the future, maybe something like element.createDOMTokenList()?