comparison 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
comparison
equal deleted inserted replaced
272:41a49c29a28f 273:e02e2036ef84
1 /**
2 * Layer 2 modal composites.
3 *
4 * Dependencies flow downward only: Zenbu Layer 1 primitives and Layer 0
5 * themes/i18n/API mapping. Application routes and state remain outside.
6 */
7
8 let nextModalId = 1;
9
10 /**
11 * Validates and labels a site modal composed with zen-dialog.
12 *
13 * @extends {HTMLElement}
14 */
15 class MjjModal extends HTMLElement {
16 connectedCallback() {
17 this._observer ||= new MutationObserver(() => this.sync());
18 this._observer.observe(this, { childList: true, subtree: true });
19 this.sync();
20 }
21
22 disconnectedCallback() {
23 this._observer?.disconnect();
24 }
25
26 /**
27 * Applies the strict modal region contract.
28 */
29 sync() {
30 const owner = this.querySelector(":scope > zen-dialog");
31 const dialog = owner?.querySelector(":scope > dialog");
32 const header = dialog?.querySelector(":scope > [data-modal-header]");
33 const body = dialog?.querySelector(":scope > [data-modal-body]");
34 const footer = dialog?.querySelector(":scope > [data-modal-footer]");
35 const title = header?.querySelector("[data-modal-title]");
36 const regions = dialog
37 ? [...dialog.children].filter(element =>
38 element.matches(
39 "[data-modal-header], [data-modal-body], [data-modal-footer]",
40 ))
41 : [];
42 const regionOrder = regions.map(element => {
43 if (element.hasAttribute("data-modal-header")) return "header";
44 if (element.hasAttribute("data-modal-body")) return "body";
45 return "footer";
46 });
47 const expectedOrder = footer
48 ? ["header", "body", "footer"]
49 : ["header", "body"];
50 const errors = [];
51
52 if (!owner) errors.push("requires one direct zen-dialog");
53 if (!dialog) errors.push("requires one direct native dialog");
54 if (!header) errors.push("requires a direct data-modal-header region");
55 if (!body) errors.push("requires a direct data-modal-body region");
56 if (!title) errors.push("requires data-modal-title inside the header");
57 if (regionOrder.join(",") !== expectedOrder.join(",")) {
58 errors.push(`regions must be ordered ${expectedOrder.join(", ")}`);
59 }
60
61 if (errors.length) {
62 this.dataset.invalid = "";
63 this.removeAttribute("data-ready");
64 const message = `${this.localName}: ${errors.join("; ")}`;
65 if (this._lastError !== message) {
66 this._lastError = message;
67 console.error(message);
68 this.dispatchEvent(new CustomEvent("mjj-modal-error", {
69 bubbles: true,
70 detail: { errors: [...errors] },
71 }));
72 }
73 return;
74 }
75
76 delete this.dataset.invalid;
77 this.dataset.ready = "";
78 this.dataset.componentLayer = "2";
79 this._lastError = null;
80 if (!title.id) title.id = `mjj-modal-title-${nextModalId++}`;
81 dialog.setAttribute("aria-labelledby", title.id);
82 dialog.setAttribute("aria-modal", "true");
83 const description = body.querySelector("[data-modal-description]");
84 if (description) {
85 if (!description.id) {
86 description.id = `mjj-modal-description-${nextModalId++}`;
87 }
88 dialog.setAttribute("aria-describedby", description.id);
89 } else {
90 dialog.removeAttribute("aria-describedby");
91 }
92 }
93
94 /**
95 * Opens the composed Zenbu dialog.
96 */
97 open() {
98 this.querySelector(":scope > zen-dialog")?.open();
99 }
100
101 /**
102 * Closes the composed Zenbu dialog.
103 *
104 * @param {string=} returnValue
105 */
106 close(returnValue) {
107 this.querySelector(":scope > zen-dialog")?.close(returnValue);
108 }
109 }
110
111 /**
112 * Presents long-form editorial content with a reading-focused body.
113 *
114 * @extends {MjjModal}
115 */
116 export class MjjContentModal extends MjjModal {}
117
118 /**
119 * Presents application content in a strict window-style frame.
120 *
121 * @extends {MjjModal}
122 */
123 export class MjjWindowModal extends MjjModal {}
124
125 if (!customElements.get("mjj-content-modal")) {
126 customElements.define("mjj-content-modal", MjjContentModal);
127 }
128 if (!customElements.get("mjj-window-modal")) {
129 customElements.define("mjj-window-modal", MjjWindowModal);
130 }