| 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">×</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; |