diff --git a/src/utils/ModalManager.js b/src/utils/ModalManager.js new file mode 100644 index 0000000..54b5b76 --- /dev/null +++ b/src/utils/ModalManager.js @@ -0,0 +1,218 @@ +/** + * ModalManager - Unified modal system with accessibility support + * @version 4.0.0 + */ +export class ModalManager { + #modal; + #backdrop; + #options; + #lastFocusedElement; + #focusableElements; + #eventListeners; + #isOpen; + + static #openModals = []; + + constructor(element, options = {}) { + this.#modal = typeof element === 'string' ? document.querySelector(element) : element; + this.#options = { + closeOnBackdrop: true, + closeOnEscape: true, + focusFirst: true, + onOpen: null, + onClose: null, + ...options + }; + this.#isOpen = false; + this.#eventListeners = []; + this.#init(); + } + + #init() { + // Ensure modal has required attributes + if (!this.#modal.hasAttribute('role')) { + this.#modal.setAttribute('role', 'dialog'); + } + if (!this.#modal.hasAttribute('aria-modal')) { + this.#modal.setAttribute('aria-modal', 'true'); + } + + // Find or create backdrop + this.#backdrop = this.#modal.querySelector('.modal-backdrop'); + + // Setup close triggers + this.#setupCloseTriggers(); + } + + #setupCloseTriggers() { + // Close button + const closeBtn = this.#modal.querySelector('.modal-close'); + if (closeBtn) { + const handler = (e) => { + e.preventDefault(); + this.close(); + }; + closeBtn.addEventListener('click', handler); + this.#eventListeners.push({ el: closeBtn, type: 'click', handler }); + } + + // Elements with data-close attribute + const closeTriggers = this.#modal.querySelectorAll('[data-close]'); + closeTriggers.forEach(el => { + if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) { + return; + } + const handler = (e) => { + e.preventDefault(); + this.close(); + }; + el.addEventListener('click', handler); + this.#eventListeners.push({ el, type: 'click', handler }); + }); + } + + #getFocusableElements() { + const selector = [ + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'a[href]', + '[tabindex]:not([tabindex="-1"])' + ].join(', '); + + return Array.from(this.#modal.querySelectorAll(selector)) + .filter(el => el.offsetParent !== null && !el.classList.contains('modal-backdrop')); + } + + #trapFocus(e) { + if (e.key !== 'Tab') return; + + const focusable = this.#getFocusableElements(); + if (focusable.length === 0) return; + + const firstEl = focusable[0]; + const lastEl = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === firstEl) { + e.preventDefault(); + lastEl.focus(); + } + } else { + if (document.activeElement === lastEl) { + e.preventDefault(); + firstEl.focus(); + } + } + } + + #handleKeydown(e) { + if (e.key === 'Escape' && this.#options.closeOnEscape) { + e.preventDefault(); + this.close(); + } + this.#trapFocus(e); + } + + open() { + if (this.#isOpen) return; + + // Store last focused element + this.#lastFocusedElement = document.activeElement; + + // Track open modals + ModalManager.#openModals.push(this); + + // Show modal + this.#modal.classList.add('open'); + this.#isOpen = true; + + // Prevent body scroll + document.body.style.overflow = 'hidden'; + + // Add keyboard listener + const keydownHandler = (e) => this.#handleKeydown(e); + document.addEventListener('keydown', keydownHandler); + this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler }); + + // Focus first element + if (this.#options.focusFirst) { + requestAnimationFrame(() => { + const focusable = this.#getFocusableElements(); + if (focusable.length > 0) { + focusable[0].focus(); + } + }); + } + + // Callback + if (this.#options.onOpen) { + this.#options.onOpen(this); + } + + // Dispatch custom event + this.#modal.dispatchEvent(new CustomEvent('modal:open')); + } + + close() { + if (!this.#isOpen) return; + + // Remove from open modals + const index = ModalManager.#openModals.indexOf(this); + if (index > -1) { + ModalManager.#openModals.splice(index, 1); + } + + // Hide modal + this.#modal.classList.remove('open'); + this.#isOpen = false; + + // Restore body scroll if no modals open + if (ModalManager.#openModals.length === 0) { + document.body.style.overflow = ''; + } + + // Remove keyboard listener + const keydownListener = this.#eventListeners.find( + l => l.el === document && l.type === 'keydown' + ); + if (keydownListener) { + document.removeEventListener('keydown', keydownListener.handler); + this.#eventListeners = this.#eventListeners.filter(l => l !== keydownListener); + } + + // Restore focus + if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') { + this.#lastFocusedElement.focus(); + } + + // Callback + if (this.#options.onClose) { + this.#options.onClose(this); + } + + // Dispatch custom event + this.#modal.dispatchEvent(new CustomEvent('modal:close')); + } + + isOpen() { + return this.#isOpen; + } + + destroy() { + // Close if open + if (this.#isOpen) { + this.close(); + } + + // Remove all event listeners + this.#eventListeners.forEach(({ el, type, handler }) => { + el.removeEventListener(type, handler); + }); + this.#eventListeners = []; + } +} + +// Export for use in renderer +window.ModalManager = ModalManager;