Skip to main content

Extending components

Build on the components, don't fork them

Every xoji element extends one blessed base, XojiElement, and that base is the extension contract too. So when a component is close to what you need but not quite (you need a control that is semantically a toggle, or an element the set doesn't ship), you build on top instead of rebuilding bespoke. The shared token sheet and the coverage contract come with you either way.

When to extend

The components compose for appearance through ::part() hooks and slots: restyle internals, inject content, no fork required. This page is the other axis: composing for behavior or semantics. Reach for it when you need a control the set doesn't ship, or a richer state on one it does.

Two paths, one base
Extend XojiElement directly for a fully custom element, or subclass a shipped element (XojiButton, XojiField, …) to add behavior to what it already renders.

The extension contract

These protected members are the stable surface a subclass builds on. render() adopts the one shared component stylesheet for you, then writes styles() + template() into the shadow root, so a subclass never re-implements theming, only its own markup and the state that drives it.

MemberTypeWhat it's for
root ShadowRoot the open shadow root; write your markup into it
template() () => string required; the element's shadow markup
styles() () => string per-element host-layout CSS (the shared token sheet is adopted for you)
render() () => void paint the shadow; call it after a state change to re-render
reflectBoolean(name, on) (string, boolean) => void reflect a boolean prop to a present/absent attribute
reflectString(name, value) (string, string?) => void reflect a string prop, removing the attribute when empty

A custom element

Extend XojiElement, declare the attributes you observe, override template() and styles(), and call render() on the state changes you care about. The markup reads straight from the token register, so it re-themes with every other component when the theme changes.

import { XojiElement, define } from "@xoji/core/elements";

// A small custom element: a click-to-increment tally, themed entirely from
// the token register the active algorithm derives.
class TallyChip extends XojiElement {
  static get observedAttributes() {
    return ["count"];
  }

  get count(): number {
    return Number(this.getAttribute("count") ?? "0");
  }
  set count(value: number) {
    this.setAttribute("count", String(value));
  }

  connectedCallback(): void {
    super.connectedCallback(); // paints once
    this.addEventListener("click", () => {
      this.count += 1;
      this.render(); // re-paint on the state change you care about
    });
  }

  protected styles(): string {
    return `:host { display: inline-block; }
      button {
        font: inherit;
        color: var(--accent-fg);
        background: var(--accent);
        border: var(--border-thin) solid var(--accent);
        border-radius: var(--radius-md);
        padding: var(--space-2) var(--space-3);
        cursor: pointer;
      }`;
  }

  protected template(): string {
    return `<button part="chip" type="button">Tally: ${this.count}</button>`;
  }
}

define("xoji-tally-chip", TallyChip);

Extending a shipped element

To add behavior to a component the set already ships, subclass its element and widen observedAttributes by spreading the base's. The inherited render() keeps painting the component's look; your subclass adds only the new behavior, here the controlled toggle the base deliberately left to the consumer.

import { XojiButton } from "@xoji/core/elements";

// A controlled toggle built on the shipped button: it owns no new look, just
// the behavior the base left to the consumer — a click flips `pressed`, which
// the base already reflects to `aria-pressed` and paints as a sunken state.
class ToggleButton extends XojiButton {
  static get observedAttributes() {
    return [...super.observedAttributes, "data-toggle"];
  }

  connectedCallback(): void {
    super.connectedCallback();
    this.addEventListener("click", () => {
      this.pressed = !this.pressed;
    });
  }
}

customElements.define("x-toggle-button", ToggleButton);

New tokens, same contract

A subclass is held to the same hard rule as a first-party component: every theme token its CSS reads must be one the register produces. If your extension paints with a token the base never consumed, declare it in the component's manifest so the coverage check stays honest.

// A subclass that paints with a token the base never consumed declares it,
// so the coverage check still holds: every token the CSS reads is one the
// register produces.
protected styles(): string {
  return `button { box-shadow: 0 0 0 2px var(--ring); }`;
  // → add "--ring" to this component's consumedTokens manifest entry
}