view mrjunejune/src/public/mjj-modal.js @ 273:e02e2036ef84 default tip

add Layer 2 JRPG component system Add reusable content and window modals, an isolated component sandbox, shared cyberpunk scroll areas, production-safe cache freshness, and server-rendered JRPG panel state. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Sat, 08 Aug 2026 02:08:08 -0700
parents
children
line wrap: on
line source

/**
 * Layer 2 modal composites.
 *
 * Dependencies flow downward only: Zenbu Layer 1 primitives and Layer 0
 * themes/i18n/API mapping. Application routes and state remain outside.
 */

let nextModalId = 1;

/**
 * Validates and labels a site modal composed with zen-dialog.
 *
 * @extends {HTMLElement}
 */
class MjjModal extends HTMLElement {
  connectedCallback() {
    this._observer ||= new MutationObserver(() => this.sync());
    this._observer.observe(this, { childList: true, subtree: true });
    this.sync();
  }

  disconnectedCallback() {
    this._observer?.disconnect();
  }

  /**
   * Applies the strict modal region contract.
   */
  sync() {
    const owner = this.querySelector(":scope > zen-dialog");
    const dialog = owner?.querySelector(":scope > dialog");
    const header = dialog?.querySelector(":scope > [data-modal-header]");
    const body = dialog?.querySelector(":scope > [data-modal-body]");
    const footer = dialog?.querySelector(":scope > [data-modal-footer]");
    const title = header?.querySelector("[data-modal-title]");
    const regions = dialog
      ? [...dialog.children].filter(element =>
          element.matches(
            "[data-modal-header], [data-modal-body], [data-modal-footer]",
          ))
      : [];
    const regionOrder = regions.map(element => {
      if (element.hasAttribute("data-modal-header")) return "header";
      if (element.hasAttribute("data-modal-body")) return "body";
      return "footer";
    });
    const expectedOrder = footer
      ? ["header", "body", "footer"]
      : ["header", "body"];
    const errors = [];

    if (!owner) errors.push("requires one direct zen-dialog");
    if (!dialog) errors.push("requires one direct native dialog");
    if (!header) errors.push("requires a direct data-modal-header region");
    if (!body) errors.push("requires a direct data-modal-body region");
    if (!title) errors.push("requires data-modal-title inside the header");
    if (regionOrder.join(",") !== expectedOrder.join(",")) {
      errors.push(`regions must be ordered ${expectedOrder.join(", ")}`);
    }

    if (errors.length) {
      this.dataset.invalid = "";
      this.removeAttribute("data-ready");
      const message = `${this.localName}: ${errors.join("; ")}`;
      if (this._lastError !== message) {
        this._lastError = message;
        console.error(message);
        this.dispatchEvent(new CustomEvent("mjj-modal-error", {
          bubbles: true,
          detail: { errors: [...errors] },
        }));
      }
      return;
    }

    delete this.dataset.invalid;
    this.dataset.ready = "";
    this.dataset.componentLayer = "2";
    this._lastError = null;
    if (!title.id) title.id = `mjj-modal-title-${nextModalId++}`;
    dialog.setAttribute("aria-labelledby", title.id);
    dialog.setAttribute("aria-modal", "true");
    const description = body.querySelector("[data-modal-description]");
    if (description) {
      if (!description.id) {
        description.id = `mjj-modal-description-${nextModalId++}`;
      }
      dialog.setAttribute("aria-describedby", description.id);
    } else {
      dialog.removeAttribute("aria-describedby");
    }
  }

  /**
   * Opens the composed Zenbu dialog.
   */
  open() {
    this.querySelector(":scope > zen-dialog")?.open();
  }

  /**
   * Closes the composed Zenbu dialog.
   *
   * @param {string=} returnValue
   */
  close(returnValue) {
    this.querySelector(":scope > zen-dialog")?.close(returnValue);
  }
}

/**
 * Presents long-form editorial content with a reading-focused body.
 *
 * @extends {MjjModal}
 */
export class MjjContentModal extends MjjModal {}

/**
 * Presents application content in a strict window-style frame.
 *
 * @extends {MjjModal}
 */
export class MjjWindowModal extends MjjModal {}

if (!customElements.get("mjj-content-modal")) {
  customElements.define("mjj-content-modal", MjjContentModal);
}
if (!customElements.get("mjj-window-modal")) {
  customElements.define("mjj-window-modal", MjjWindowModal);
}