diff 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 diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/mjj-modal.js	Sat Aug 08 02:08:08 2026 -0700
@@ -0,0 +1,130 @@
+/**
+ * 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);
+}