From b27bc7fc6f63e57d9604fe82b2ea9480af745de5 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Wed, 7 Oct 2026 05:27:51 -0400 Subject: [PATCH] Improve nested modal handling --- scripts/modal.js | 92 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 16 deletions(-) diff --git a/scripts/modal.js b/scripts/modal.js index d04fc4f..805e75e 100644 --- a/scripts/modal.js +++ b/scripts/modal.js @@ -6,9 +6,13 @@ window.ModalSystem = (function() { 'use strict'; - // Store active modals + // Store active modals (insertion order = stack order) const activeModals = new Map(); + // Single shared backdrop for the whole stack (max one in the DOM) + let sharedBackdropEl = null; + let sharedBackdropClickBound = false; + // Body scroll lock while any kefin modal is open (replaces non-passive wheel trap) let bodyScrollLockCount = 0; let savedBodyOverflow = null; @@ -155,6 +159,48 @@ window.ModalSystem = (function() { return el && typeof el.closest === 'function' ? el.closest('.dialog') : null; } + function getTopmostModalId() { + const ids = Array.from(activeModals.keys()); + return ids.length ? ids[ids.length - 1] : null; + } + + /** Backdrop click closes the topmost modal that opted into closeOnBackdrop. */ + function onSharedBackdropClick() { + const ids = Array.from(activeModals.keys()); + for (let i = ids.length - 1; i >= 0; i--) { + const modal = activeModals.get(ids[i]); + if (modal?.closeOnBackdrop) { + closeModal(ids[i]); + return; + } + } + } + + function ensureSharedBackdrop(backdrop) { + const existing = sharedBackdropEl + && document.body.contains(sharedBackdropEl) + ? sharedBackdropEl + : document.body.querySelector('.dialogBackdrop[data-kefin-modal-backdrop="true"]'); + + if (existing) { + sharedBackdropEl = existing; + if (!sharedBackdropClickBound) { + existing.addEventListener('click', onSharedBackdropClick); + sharedBackdropClickBound = true; + } + return existing; + } + + backdrop.setAttribute('data-kefin-modal-backdrop', 'true'); + document.body.appendChild(backdrop); + sharedBackdropEl = backdrop; + if (!sharedBackdropClickBound) { + backdrop.addEventListener('click', onSharedBackdropClick); + sharedBackdropClickBound = true; + } + return backdrop; + } + function getFocusRows(dialogEl) { const rows = Array.from(dialogEl.querySelectorAll('[data-kefin-focus-row]')); const withItems = rows.map((row) => ({ @@ -464,23 +510,21 @@ window.ModalSystem = (function() { } dialogContainer.appendChild(dialog); - // Add to DOM - // Check if the modal backdrop is already in the DOM - if (!document.body.querySelector('.dialogBackdrop')) { - document.body.appendChild(backdrop); - } + // Single shared backdrop for the stack; nested modals reuse the same DOM node + const resolvedBackdrop = ensureSharedBackdrop(backdrop); document.body.appendChild(dialogContainer); // Create modal instance const modalInstance = { id, - backdrop, + backdrop: resolvedBackdrop, dialogContainer, dialog, dialogContent, dialogHeader, dialogFooter, isOpen: true, + closeOnBackdrop: !!closeOnBackdrop, onClose: typeof onClose === 'function' ? onClose : null, returnFocusEl, close: () => closeModal(id), @@ -496,9 +540,8 @@ window.ModalSystem = (function() { activeModals.set(id, modalInstance); //lockBodyScroll(); - // Add event listeners + // dialogContainer click closes this modal; shared backdrop always closes topmost if (closeOnBackdrop) { - backdrop.addEventListener('click', () => closeModal(id)); dialogContainer.addEventListener('click', (e) => { if (e.target === dialogContainer) { closeModal(id); @@ -543,13 +586,25 @@ window.ModalSystem = (function() { } /** - * Close a modal by ID + * Close a modal by ID. Closes later-opened (nested) modals first. * @param {string} id - Modal ID + * @param {{ fromCascade?: boolean }} [options] */ - function closeModal(id) { + function closeModal(id, options = {}) { const modal = activeModals.get(id); if (!modal) return; + // Close nested modals opened after this one (reverse stack order) + if (!options.fromCascade) { + const ids = Array.from(activeModals.keys()); + const idx = ids.indexOf(id); + if (idx >= 0) { + for (let i = ids.length - 1; i > idx; i--) { + closeModal(ids[i], { fromCascade: true }); + } + } + } + // Remove escape handler if it exists if (modal._escapeHandler) { document.removeEventListener('keydown', modal._escapeHandler); @@ -567,16 +622,21 @@ window.ModalSystem = (function() { } } - // Remove from DOM - if (modal.backdrop && modal.backdrop.parentNode) { - modal.backdrop.parentNode.removeChild(modal.backdrop); - } + // Remove dialog from DOM if (modal.dialogContainer && modal.dialogContainer.parentNode) { modal.dialogContainer.parentNode.removeChild(modal.dialogContainer); } - // Remove from active modals + // Remove from active modals before deciding on shared backdrop activeModals.delete(id); + + // Shared backdrop stays while any modal remains; remove only when stack is empty + if (activeModals.size === 0 && sharedBackdropEl && sharedBackdropEl.parentNode) { + sharedBackdropEl.removeEventListener('click', onSharedBackdropClick); + sharedBackdropClickBound = false; + sharedBackdropEl.parentNode.removeChild(sharedBackdropEl); + sharedBackdropEl = null; + } //unlockBodyScroll(); // Call onClose callback if modal instance has it