Custom Element Conventions

  • Whenever possible custom elements are defined each in their own ES module

    • The file name should match the element tag name, so <my-element> is in my-element.mjs

    • The class name should match the custom element tag name, just in PascalCase.

    • The custom element registers itself in the customElements registry at the end of the file. For example

        customElements.define("my-element", MyElement);
      
    • If the element should be extended by other elements, it should be exported as a named export in the module.

  • Any other custom elements the element depends on or uses in its template should be imported in the element’s module.

  • External features of the custom element (attributes, slots, parts, css custom properties, events etc.) are documented using these jsdoc tags: https://custom-elements-manifest.open-wc.org/analyzer/getting-started/#supported-jsdoc

  • For events the handleEvents method callback should be used, and just the this reference is passed to addEventListener. See https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#the_event_listener_callback

    • This also lets us modify event handling through inheritance.

    • Any events attached to elements that aren’t within the custom element need to be removed in the disconnectedCallback (and should be registered again if the element gets re-attached).

  • If the element doesn’t attach a shadow root in its connectedCallback, consider using a hasConnected class field to detect if the main setup logic has already happened.

  • Templates are currently managed in HTML markup and we just document the ID of the template that the custom element expects as Template ID: #myElementTemplate. Often the template is in an include file we include anywhere the custom element is used.

  • Styles for the custom element are usually in a separate css file with the same name as the module. If the element has a shadow root, the stylesheet will be loaded into it.

  • When interacting with other custom elements, you should follow these methods:

    • To send (or request) information to a child element, call a method on it.

    • To send information to a parent element (without the parent requesting it in a method call), emit the information in a custom event.

  • If the state needs to be updated from a parent with an explicit function call, consider using an initialize method that is also called from the connectedCallback.

  • XPCOM Observers registered with the nsIObserverService should generally be weak observers. This is safe, because the custom element (and thus the observer) will usually be owned by a DOM scope. The observing element needs to explicitly declare the nsISupportsWeakReference and nsIObserver interfaces in the QueryInterface method. The observer should of course also be unregistered in the disconnectedCallback.

  • Unless key repeats are desired, consider using keyup listeners for keyboard event handling.

Custom Element Boilerplate

my-element.mjs

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, you can obtain one at http://mozilla.org/MPL/2.0/. */

/**
 * Example component serving as a boilerplate demonstration.
 * You should never see this description in the wild.
 * Template ID: #myElementTemplate
 *
 * @attribute {string} label - Label of the button. Observed for changes.
 * @tagname my-element
 */
export class MyElement extends HTMLElement {
  static get observedAttributes() {
    return ["label"];
  }

  QueryInterface = ChromeUtils.generateQI([
    "nsIObserver",
    "nsISupportsWeakReference"
  ]);

  connectedCallback() {
    window.addEventListener("resize", this);
    Services.obs.addObserver(this, "some-topic", true);

    if (this.shadowRoot) {
      return;
    }

    const shadowRoot = this.attachShadow({ mode: "open" });
    const styles = document.createElement("link");
    styles.rel = "stylesheet";
    styles.href = "chrome://messenger/skin/shared/my-element.css";
    const template = document
      .getElementById("myElementTemplate")
      .content.cloneNode(true);

    template.querySelector("button").addEventListener("click", this);
    template.querySelector("button").textContent = this.getAttribute("label");

    shadowRoot.append(styles, template);

    document.l10n.connectRoot(shadowRoot);
  }

  disconnectedCallback() {
    window.removeEventListener("resize", this);
    Services.obs.removeObserver(this, "some-topic");
    document.l10n.disconnectRoot(this.shadowRoot);
  }

  attributeChangedCallback(attribute) {
    switch (attribute) {
      case "label":
        this.shadowRoot.querySelector("button").textContent = this.getAttribute("label");
        break;
    }
  }

  handleEvent(event) {
    switch (event.type) {
      case "click":
        console.log("clicked!");
        break;
      case "resize":
        console.warn("Why would you want a resize listener?");
        break;
    }
  }

  observe(subject, topic, data) {
    switch(topic) {
      case "some-topic":
        console.log("observer notification");
        break;
    }
  }
}
customElements.define("my-element", MyElement);

myElementTemplate.inc.xhtml

<template id="myElementTemplate">
  <span data-l10n-id="my-element-string"></span>
  <button class="button" data-l10n-id="my-element-button"></button>
</template>

Loading Custom Elements

If the template of a custom element relies on another custom element, we will generally directly import that other custom element at the top of the module with a synchronous import statement:

import "chrome://messenger/content/other-element.mjs"; // eslint-disable-line import/no-unassigned-import

If we’re dynamically instantiating a custom element, if we can control it only happening once consider either using an import() call, ChromeUtils.importESModule or ChromeUtils.defineESModuleGetters() to only load the module when used:

async function createElementWithImport() {
  if (hasElement) {
    return;
  }
  await import("chrome://messenger/content/other-element.mjs");
  document.createElement("other-element");
}

function createElementSynchronously() {
  if (hasElement) {
    return;
  }
  ChromeUtils.importESModule(
    "chrome://messenger/content/other-element.mjs",
    { global: "current" }
  );
  document.createElement("other-element");
}

const lazy = {};
ChromeUtils.defineESModuleGetters(
  lazy,
  { OtherElement: "chrome://messenger/content/other-element.mjs" },
  { global: "current" }
);

function createLazyConstructedElement() {
  new lazy.OtherElement();
}

Yet another option is to use defineLazyCustomElement from chrome://messenger/content/CustomElementUtils.mjs. It is useful in situations where the previous options are hard to use.

defineLazyCustomElement(
  "other-element",
  "chrome://messenger/content/other-element.mjs"
);

function createLazyElement() {
  document.createElement("other-element");
}