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