Mercurial
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); }