main
js 582 lines 17.9 KB
Raw
1 // Import the component loader and page utilities
2 import { importComponent } from "/js/components.js";
3 import { callJsExtensions } from "/js/extensions.js";
4
5 // Modal functionality
6 const modalStack = [];
7 const RESTORABLE_MODAL_STACK_KEY = "a0.modalStack.restorable";
8 let restoringModalSession = false;
9 let restoredModalSession = false;
10
11 function sameModalPath(left = "", right = "") {
12 return String(left || "").replace(/^\/+/, "") === String(right || "").replace(/^\/+/, "");
13 }
14
15 function isReloadNavigation() {
16 const navigation = performance.getEntriesByType?.("navigation")?.[0];
17 if (navigation?.type) return navigation.type === "reload";
18 if (!performance.navigation) return false;
19 return performance.navigation?.type === performance.navigation?.TYPE_RELOAD;
20 }
21
22 function modalHasClass(modalOrElement, className) {
23 const element = modalOrElement?.element || modalOrElement;
24 return Boolean(
25 element?.classList?.contains(className)
26 || element?.querySelector?.(".modal-inner")?.classList?.contains(className)
27 );
28 }
29
30 function modalDatasetFlag(modalOrElement, name) {
31 const element = modalOrElement?.element || modalOrElement;
32 const inner = element?.querySelector?.(".modal-inner");
33 const value = element?.dataset?.[name] ?? inner?.dataset?.[name] ?? "";
34 return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
35 }
36
37 function modalRequiresExplicitClose(modalOrElement) {
38 return modalHasClass(modalOrElement, "modal-explicit-close")
39 || modalDatasetFlag(modalOrElement, "modalExplicitClose");
40 }
41
42 function modalSuppressesBackdrop(modalOrElement) {
43 return modalHasClass(modalOrElement, "modal-no-backdrop")
44 || modalDatasetFlag(modalOrElement, "modalNoBackdrop");
45 }
46
47 function modalRestoreMode(modalOrElement) {
48 const element = modalOrElement?.element || modalOrElement;
49 const inner = element?.querySelector?.(".modal-inner");
50 return String(element?.dataset?.modalRestore || inner?.dataset?.modalRestore || "").trim();
51 }
52
53 function modalCanRestore(modalOrElement) {
54 return modalRestoreMode(modalOrElement) === "surface";
55 }
56
57 function restorableModalSnapshot() {
58 return modalStack
59 .filter((modal) => modalCanRestore(modal))
60 .map((modal) => ({ path: modal.path }));
61 }
62
63 export function persistRestorableModalStack(options = {}) {
64 if (restoringModalSession && options.force !== true) return;
65 try {
66 const modals = restorableModalSnapshot();
67 if (modals.length === 0) {
68 sessionStorage.removeItem(RESTORABLE_MODAL_STACK_KEY);
69 return;
70 }
71 sessionStorage.setItem(
72 RESTORABLE_MODAL_STACK_KEY,
73 JSON.stringify({
74 version: 1,
75 modals,
76 }),
77 );
78 } catch (error) {
79 console.warn("Could not persist restorable modals", error);
80 }
81 }
82
83 function dispatchModalEvent(name, modal, detail = {}) {
84 document.dispatchEvent(
85 new CustomEvent(name, {
86 detail: {
87 modalPath: modal?.path ?? null,
88 modal,
89 modalStack: getModalStack(),
90 ...detail,
91 },
92 }),
93 );
94 }
95
96 function activateModal(modal) {
97 if (!modal) return;
98 updateModalZIndexes();
99 restoreModalScrollSnapshot(modal);
100 dispatchModalEvent("modal-activated", modal);
101 persistRestorableModalStack();
102 }
103
104 function findModalIndexByPath(modalPath) {
105 return modalStack.findIndex((modal) => sameModalPath(modal.path, modalPath));
106 }
107
108 function focusModal(modalPath) {
109 const modalIndex = findModalIndexByPath(modalPath);
110 if (modalIndex === -1) return false;
111 const currentTopModal = modalStack[modalStack.length - 1];
112 if (currentTopModal) {
113 currentTopModal.savedScrollSnapshot = captureModalScrollSnapshot(currentTopModal);
114 }
115 const [modal] = modalStack.splice(modalIndex, 1);
116 modalStack.push(modal);
117 activateModal(modal);
118 return true;
119 }
120
121 function getModalScrollElement(modal) {
122 return modal?.element?.querySelector(".modal-scroll");
123 }
124
125 function captureModalScrollSnapshot(modal) {
126 const modalScroll = getModalScrollElement(modal);
127 if (!modalScroll) return null;
128 return {
129 scrollTop: modalScroll.scrollTop,
130 scrollLeft: modalScroll.scrollLeft,
131 };
132 }
133
134 function restoreModalScrollSnapshot(modal) {
135 const snapshot = modal?.savedScrollSnapshot;
136 if (!snapshot) return;
137
138 requestAnimationFrame(() => {
139 const modalScroll = getModalScrollElement(modal);
140 if (!modalScroll) return;
141 modalScroll.scrollTop = snapshot.scrollTop;
142 modalScroll.scrollLeft = snapshot.scrollLeft;
143 modal.savedScrollSnapshot = null;
144 });
145 }
146
147 // Create a single backdrop for all modals
148 const backdrop = document.createElement("div");
149 backdrop.className = "modal-backdrop";
150 backdrop.style.display = "none";
151 backdrop.style.backdropFilter = "blur(8px) saturate(112%)";
152 document.body.appendChild(backdrop);
153
154 // Function to update z-index for all modals and backdrop
155 function updateModalZIndexes() {
156 // Base z-index for modals
157 const baseZIndex = 5000;
158
159 // Update z-index for all modals
160 modalStack.forEach((modal, index) => {
161 // For first modal, z-index is baseZIndex
162 // For second modal, z-index is baseZIndex + 20
163 // This leaves room for the backdrop between them
164 modal.element.style.zIndex = baseZIndex + index * 20;
165 });
166
167 const backdropModalStack = modalStack.filter((modal) => !modalSuppressesBackdrop(modal));
168
169 if (backdropModalStack.length === 0) {
170 backdrop.style.display = "none";
171 return;
172 }
173
174 backdrop.style.display = "block";
175 backdrop.style.backdropFilter = "blur(8px) saturate(112%)";
176 backdrop.style.backgroundColor = "";
177
178 if (backdropModalStack.length === modalStack.length && modalStack.length > 1) {
179 const topModalIndex = modalStack.length - 1;
180 backdrop.style.zIndex = baseZIndex + (topModalIndex - 1) * 20 + 10;
181 } else {
182 const topBackdropModal = backdropModalStack[backdropModalStack.length - 1];
183 const topBackdropModalIndex = modalStack.indexOf(topBackdropModal);
184 backdrop.style.zIndex = topBackdropModalIndex > 0
185 ? baseZIndex + (topBackdropModalIndex - 1) * 20 + 10
186 : baseZIndex - 1;
187 }
188 }
189
190 // Function to create a new modal element
191 function createModalElement(path) {
192 // Create modal element
193 const newModal = document.createElement("div");
194 newModal.className = "modal";
195 newModal.path = path; // save name to the object
196 newModal.dataset.modalPath = path;
197
198 // Add click handlers to only close modal if both mousedown and mouseup are on the modal container
199 let mouseDownTarget = null;
200 newModal.addEventListener("mousedown", (event) => {
201 mouseDownTarget = event.target;
202 });
203 newModal.addEventListener("mouseup", (event) => {
204 if (
205 event.target === newModal
206 && mouseDownTarget === newModal
207 && !modalRequiresExplicitClose(newModal)
208 ) {
209 closeModal();
210 }
211 mouseDownTarget = null;
212 });
213
214
215 // Create modal structure
216 newModal.innerHTML = `
217 <div class="modal-inner" x-data>
218 <x-extension id="modal-shell-start"></x-extension>
219 <div class="modal-header">
220 <h2 class="modal-title"></h2>
221 <button class="modal-close">&times;</button>
222 </div>
223 <div class="modal-scroll">
224 <div class="modal-bd"></div>
225 </div>
226 <div class="modal-footer-slot" style="display: none;"></div>
227 <x-extension id="modal-shell-end"></x-extension>
228 </div>
229 `;
230
231 // Setup close button handler for this specific modal
232 const close_button = newModal.querySelector(".modal-close");
233 close_button.addEventListener("click", () => closeModal());
234
235
236 // Add modal to DOM
237 document.body.appendChild(newModal);
238
239 // Show the modal
240 newModal.classList.add("show");
241
242 // Update modal z-indexes
243 updateModalZIndexes();
244
245 return {
246 path: path,
247 element: newModal,
248 title: newModal.querySelector(".modal-title"),
249 header: newModal.querySelector(".modal-header"),
250 body: newModal.querySelector(".modal-bd"),
251 close: close_button,
252 footerSlot: newModal.querySelector(".modal-footer-slot"),
253 inner: newModal.querySelector(".modal-inner"),
254 styles: [],
255 scripts: [],
256 beforeClose: null,
257 savedScrollSnapshot: null,
258 };
259 }
260
261 // Function to open modal with content from URL
262 export async function openModal(modalPath, beforeClose = null) {
263 const openCtx = { modalPath, modal: null, cancel: false };
264 await callJsExtensions("open_modal_before", openCtx);
265 if (openCtx.cancel) return;
266 modalPath = openCtx.modalPath;
267
268 return new Promise((resolve) => {
269 try {
270 const currentTopModal = modalStack[modalStack.length - 1];
271 if (currentTopModal) {
272 currentTopModal.savedScrollSnapshot = captureModalScrollSnapshot(currentTopModal);
273 }
274
275 const returnFocus = document.activeElement;
276 // Create new modal instance
277 const modal = createModalElement(modalPath);
278 modal.beforeClose = beforeClose;
279 modal.returnFocus = returnFocus;
280 openCtx.modal = modal;
281
282 new MutationObserver(
283 (_, o) =>
284 !document.contains(modal.element) && (o.disconnect(), resolve())
285 ).observe(document.body, { childList: true, subtree: true });
286
287 // Set a loading state
288 modal.body.innerHTML = '<div class="loading">Loading...</div>';
289
290 // Already added to stack above
291
292 // Use importComponent to load the modal content
293 // This handles all HTML, styles, scripts and nested components
294 // Updated path to use the new folder structure with modal.html
295 const componentPath = modalPath; // `modals/${modalPath}/modal.html`;
296
297 // Use importComponent which now returns the parsed document
298 importComponent(componentPath, modal.body)
299 .then(async (doc) => {
300 // Set the title from the document
301 modal.title.innerHTML = doc.title || modalPath;
302 const htmlElement = doc.documentElement;
303 if (htmlElement && htmlElement.classList) {
304 const inner = modal.element.querySelector(".modal-inner");
305 if (inner) inner.classList.add(...htmlElement.classList);
306 }
307 if (doc.body && doc.body.classList) {
308 modal.body.classList.add(...doc.body.classList);
309 }
310 await callJsExtensions("modal_content_loaded", {
311 modalPath,
312 modal,
313 doc,
314 });
315 dispatchModalEvent("modal-content-loaded", modal, { doc });
316 refreshModalStack();
317
318 // Some modals have a footer. Check if it exists and move it to footer slot
319 // Use requestAnimationFrame to let Alpine mount the component first
320 requestAnimationFrame(() => {
321 const componentFooter = modal.body.querySelector('[data-modal-footer]');
322 if (componentFooter && modal.footerSlot) {
323 // Move footer outside modal-scroll scrollable area
324 modal.footerSlot.appendChild(componentFooter);
325 modal.footerSlot.style.display = 'block';
326 modal.inner.classList.add('modal-with-footer');
327 }
328 });
329 })
330 .catch((error) => {
331 console.error("Error loading modal content:", error);
332 modal.body.innerHTML = `<div class="error">Failed to load modal content: ${error.message}</div>`;
333 });
334
335 // Add modal to stack and show it
336 // Add modal to stack
337 modal.path = modalPath;
338 modalStack.push(modal);
339 document.body.style.overflow = "hidden";
340
341 activateModal(modal);
342 } catch (error) {
343 console.error("Error loading modal content:", error);
344 resolve();
345 }
346 });
347 }
348
349 export function isModalOpen(modalPath) {
350 return findModalIndexByPath(modalPath) !== -1;
351 }
352
353 export function getModalStack() {
354 return modalStack.slice();
355 }
356
357 export function refreshModalStack() {
358 if (modalStack.length === 0) {
359 updateModalZIndexes();
360 persistRestorableModalStack();
361 return;
362 }
363 activateModal(modalStack[modalStack.length - 1]);
364 }
365
366 export function restoreRestorableModalStack() {
367 if (restoredModalSession) return;
368 if (!isReloadNavigation()) {
369 sessionStorage.removeItem(RESTORABLE_MODAL_STACK_KEY);
370 return;
371 }
372 restoredModalSession = true;
373
374 let saved;
375 try {
376 saved = JSON.parse(sessionStorage.getItem(RESTORABLE_MODAL_STACK_KEY) || "{}");
377 } catch (error) {
378 console.warn("Could not restore restorable modals", error);
379 sessionStorage.removeItem(RESTORABLE_MODAL_STACK_KEY);
380 return;
381 }
382
383 const paths = Array.isArray(saved?.modals)
384 ? saved.modals
385 .map((entry) => String(entry?.path || "").trim())
386 .filter(Boolean)
387 : [];
388 if (paths.length === 0) return;
389
390 restoringModalSession = true;
391 for (const path of paths) {
392 try {
393 const openPromise = ensureModalOpen(path);
394 openPromise?.catch?.((error) => console.error(`Failed to restore modal ${path}`, error));
395 } catch (error) {
396 console.error(`Failed to restore modal ${path}`, error);
397 }
398 }
399
400 globalThis.setTimeout?.(() => {
401 restoringModalSession = false;
402 persistRestorableModalStack({ force: true });
403 }, 1500);
404 }
405
406 export async function ensureModalOpen(modalPath, beforeClose = null) {
407 if (focusModal(modalPath)) return null;
408 return openModal(modalPath, beforeClose);
409 }
410
411 export async function toggleModal(modalPath, beforeClose = null) {
412 if (!isModalOpen(modalPath)) {
413 return openModal(modalPath, beforeClose);
414 }
415 while (isModalOpen(modalPath)) {
416 const closed = await closeModal(modalPath);
417 if (closed === false) return false;
418 }
419 return true;
420 }
421
422 // Function to close modal
423 export async function closeModal(modalPath = null) {
424 if (modalStack.length === 0) return;
425
426 let modalIndex = modalStack.length - 1; // Default to last modal
427 let modal;
428
429 if (modalPath) {
430 // Find the modal with the specified name in the stack
431 modalIndex = findModalIndexByPath(modalPath);
432 if (modalIndex === -1) return; // Modal not found in stack
433
434 // Get the modal from stack at the found index
435 modal = modalStack[modalIndex];
436 } else {
437 // Just get the last modal (removal happens after beforeClose)
438 modal = modalStack[modalStack.length - 1];
439 }
440 const wasTop = modalIndex === modalStack.length - 1;
441
442 const closeCtx = { modalPath: modalPath ?? null, modal, cancel: false };
443 await callJsExtensions("close_modal_before", closeCtx);
444 if (closeCtx.cancel) return false;
445
446 const canClose = async () => {
447 if (!modal.beforeClose) return true;
448 try {
449 const result = await Promise.resolve(modal.beforeClose());
450 return result !== false;
451 } catch (error) {
452 console.error("Error in beforeClose handler:", error);
453 return true;
454 }
455 };
456
457 return Promise.resolve(canClose()).then((shouldClose) => {
458 if (!shouldClose) return false;
459
460 if (modalPath) {
461 // Remove the modal from stack after beforeClose check
462 modalStack.splice(modalIndex, 1);
463 } else {
464 modalStack.pop();
465 }
466
467 // Remove modal-specific styles and scripts immediately
468 modal.styles.forEach((styleId) => {
469 document.querySelector(`[data-modal-style="${styleId}"]`)?.remove();
470 });
471 modal.scripts.forEach((scriptId) => {
472 document.querySelector(`[data-modal-script="${scriptId}"]`)?.remove();
473 });
474
475 // First remove the show class to trigger the transition
476 modal.element.classList.remove("show");
477
478 // commented out to prevent race conditions
479
480 // // Remove the modal element from DOM after animation
481 // modal.element.addEventListener(
482 // "transitionend",
483 // () => {
484 // // Make sure the modal is completely removed from the DOM
485 // if (modal.element.parentNode) {
486 // modal.element.parentNode.removeChild(modal.element);
487 // }
488 // },
489 // { once: true }
490 // );
491
492 // // Fallback in case the transition event doesn't fire
493 // setTimeout(() => {
494 // if (modal.element.parentNode) {
495 // modal.element.parentNode.removeChild(modal.element);
496 // }
497 // }, 500); // 500ms should be enough for the transition to complete
498
499 // remove immediately
500 if (modal.element.parentNode) {
501 modal.element.parentNode.removeChild(modal.element);
502 }
503
504
505 // Handle backdrop visibility and body overflow
506 if (modalStack.length === 0) {
507 // Hide backdrop when no modals are left
508 backdrop.style.display = "none";
509 document.body.style.overflow = "";
510 } else {
511 activateModal(modalStack[modalStack.length - 1]);
512 }
513 if (wasTop && modal.returnFocus?.isConnected) modal.returnFocus.focus();
514
515 document.dispatchEvent(
516 new CustomEvent("modal-closed", {
517 detail: {
518 modalPath: modal.path ?? null,
519 remainingModalCount: modalStack.length,
520 },
521 }),
522 );
523 persistRestorableModalStack();
524
525 return true;
526 });
527 }
528
529 // Function to scroll to element by ID within the last modal
530 export function scrollModal(id) {
531 if (!id) return;
532
533 // Get the last modal in the stack
534 const lastModal = modalStack[modalStack.length - 1].element;
535 if (!lastModal) return;
536
537 // Find the modal container and target element
538 const modalContainer = lastModal.querySelector(".modal-scroll");
539 const targetElement = lastModal.querySelector(`#${id}`);
540
541 if (modalContainer && targetElement) {
542 modalContainer.scrollTo({
543 top: targetElement.offsetTop - 20, // 20px padding from top
544 behavior: "smooth",
545 });
546 }
547 }
548
549 // Make scrollModal globally available
550 globalThis.scrollModal = scrollModal;
551
552 // Handle modal content loading from clicks
553 document.addEventListener("click", async (e) => {
554 const modalTrigger = e.target.closest("[data-modal-content]");
555 if (modalTrigger) {
556 e.preventDefault();
557 if (
558 modalTrigger.hasAttribute("disabled") ||
559 modalTrigger.classList.contains("disabled")
560 ) {
561 return;
562 }
563 const modalPath = modalTrigger.getAttribute("href");
564 await openModal(modalPath);
565 }
566 });
567
568 // Close modal on escape key (closes only the top modal)
569 document.addEventListener("keydown", (e) => {
570 if (e.key === "Escape" && modalStack.length > 0) {
571 if (modalRequiresExplicitClose(modalStack[modalStack.length - 1])) return;
572 closeModal();
573 }
574 });
575
576 // also export as global function
577 globalThis.openModal = openModal;
578 globalThis.closeModal = closeModal;
579 globalThis.scrollModal = scrollModal;
580 globalThis.isModalOpen = isModalOpen;
581 globalThis.ensureModalOpen = ensureModalOpen;
582 globalThis.toggleModal = toggleModal;