js-dojo

1 define and mount

GOAL

ship a widget as a custom element, then mount it from a React page by writing its tag - no import, no shared build.

CONCEPT

a custom element is a class extending HTMLElement, handed to customElements.define under a dashed tag name. From then on the browser builds one every time that tag lands in the document, and calls its connectedCallback so the element can fill itself in. React writing the tag counts.

HINT

inside connectedCallback `this` IS the element, so the widget paints into itself. And in JSX a lowercase dashed tag is a plain DOM tag, not a reference to a component.

MIRRORS

a usage meter another team ships as a custom element and a React dashboard drops in. The host page writes <quota-meter> where the meter belongs and the meter draws its own contents - the host knowing nothing about it but the name.

Run

This koan renders React, so it needs a one-time setup.

pnpm koan 10-embedded-mods/01-define-and-mount.tsx

Source

// DOJO · Module 10 / Exercise 1 — define and mount
// GOAL: ship a widget as a custom element, then mount it from a React
//       page by writing its tag - no import, no shared build.
// CONCEPT: a custom element is a class extending HTMLElement, handed
//       to customElements.define under a dashed tag name. From then on
//       the browser builds one every time that tag lands in the
//       document, and calls its connectedCallback so the element can
//       fill itself in. React writing the tag counts.
// HINT: inside connectedCallback `this` IS the element, so the widget
//       paints into itself. And in JSX a lowercase dashed tag is a
//       plain DOM tag, not a reference to a component.
// MIRRORS: a usage meter another team ships as a custom element and a
//       React dashboard drops in. The host page writes <quota-meter>
//       where the meter belongs and the meter draws its own contents -
//       the host knowing nothing about it but the name.
// Run: pnpm koan 10-embedded-mods/01-define-and-mount.tsx

import type { HTMLAttributes } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";

afterEach(() => {
  cleanup();
  // cleanup() unmounts the React tree. The elements these tests build
  // by hand are outside it, so the fixture sweeps them up itself.
  document.body.replaceChildren();
});

// Given: JSX only accepts a tag it has been told about. This block is
// for the compiler; the browser needs no such notice.
declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "quota-meter": HTMLAttributes<HTMLElement>;
    }
  }
}

// Given: the widget's own markup. Another team wrote it and ships it.
// The host page never reads this string and never imports it.
const METER_LINE = "seats 3 / 7";
const METER_MARKUP = `<p data-meter="seats">${METER_LINE}</p>`;

// Given: the tag the widget claims. A dash in the name is what marks a
// tag as somebody's custom element, which is why you may invent one.
const METER_TAG = "quota-meter";

// --- TODO 1 ----------------------------------------------------------
// Shipping the widget is two moves, and both are missing.
// (a) connectedCallback runs when the browser puts this element into
//     the document - the widget's first chance to fill itself in.
//     `this` is the element, so give it METER_MARKUP.
// (b) on the line below the class, hand METER_TAG and the class to
//     customElements.define, so that the tag means something.
class QuotaMeter extends HTMLElement {
  connectedCallback(): void {
    // fix me (a)
  }
}
// fix me (b): customElements.define(...)

// --- TODO 2 ----------------------------------------------------------
// The dashboard is where the meter belongs. Write the widget's tag
// inside the section below - lowercase, spelled exactly the way
// METER_TAG spells it. React hands a lowercase dashed tag straight to
// the document instead of hunting for a component of that name.
function Dashboard() {
  return (
    <section aria-label="dashboard">
      {/* fix me */}
    </section>
  );
}

// --- TODO 3 ----------------------------------------------------------
// Look at what actually crossed from the widget to the host. Dashboard
// imports nothing from the widget, so the host's bundler never learns
// the class exists. One string is the whole handoff: write it down.
const HANDOFF: string | null = null;

describe("Module 10 / Exercise 1 — define and mount", () => {
  it("TODO 1 — the browser can build the widget from its tag", () => {
    expect(
      customElements.get(METER_TAG),
      "TODO 1 — register the class with customElements.define",
    ).toBe(QuotaMeter);

    const meter = document.createElement(METER_TAG);
    document.body.appendChild(meter);

    expect(
      meter,
      "TODO 1 — the tag must build a QuotaMeter, not a bare element",
    ).toBeInstanceOf(QuotaMeter);
    expect(
      meter.textContent,
      "TODO 1 — connectedCallback must paint the widget into itself",
    ).toContain(METER_LINE);
  });

  it("TODO 2 — the host mounts the widget by writing its tag", () => {
    const { container } = render(<Dashboard />);
    const mounted = container.querySelector(METER_TAG);

    expect(
      mounted,
      "TODO 2 — the dashboard must render the widget's tag",
    ).not.toBeNull();
    expect(
      document.body.contains(mounted),
      "TODO 2 — the mounted widget must sit in the host's document",
    ).toBe(true);
    expect(
      mounted?.textContent,
      "TODO 2 — the host must see the widget's own markup",
    ).toContain(METER_LINE);
  });

  it("TODO 3 — the handoff is a tag name, not an import", () => {
    expect(
      typeof HANDOFF,
      "TODO 3 — write down the one string the host needs",
    ).toBe("string");

    // Past that assertion HANDOFF is a string, so name it as one.
    const tag = String(HANDOFF);

    expect(
      customElements.get(tag),
      "TODO 3 — that name must be the tag the widget registered",
    ).toBe(QuotaMeter);

    // Nothing but the name travelled, and the name is enough.
    const spare = document.createElement(tag);
    document.body.appendChild(spare);

    expect(
      spare.textContent,
      "TODO 3 — the name alone must be enough to build the widget",
    ).toContain(METER_LINE);
  });
});

Solution