/** * Modal Manager for LiveComponents * * Manages modal stack using native element with showModal(). * * Native Features: * - Automatic backdrop/overlay (::backdrop pseudo-element) * - Automatic focus management (focus trapping) * - Automatic ESC handling (cancel event) * - Native modal semantics (blocks background interaction) * - Better accessibility (ARIA attributes) * * Browser Support: * - Chrome 37+ (2014) * - Firefox 98+ (2022) * - Safari 15.4+ (2022) */ export class ModalManager { constructor() { this.modalStack = []; // Array of { componentId, dialogElement, zIndex, closeOnEscape, closeOnBackdrop } this.baseZIndex = 1050; // Bootstrap modal default this.zIndexIncrement = 10; } /** * Open modal using native element * @param {string} componentId - Component ID * @param {Object} options - Modal options * @returns {Object} Modal instance with dialog element */ open(componentId, options = {}) { const { title = '', content = '', size = 'medium', buttons = [], closeOnBackdrop = true, closeOnEscape = true } = options; // Calculate z-index for stacking const zIndex = this.baseZIndex + (this.modalStack.length * this.zIndexIncrement); // Create native element const dialog = document.createElement('dialog'); dialog.className = `livecomponent-modal livecomponent-modal--${size}`; dialog.id = `modal-${componentId}`; dialog.style.zIndex = zIndex.toString(); // Set content dialog.innerHTML = this.createModalContent(title, content, buttons); // Add to DOM document.body.appendChild(dialog); // Setup event handlers BEFORE showing modal this.setupDialogHandlers(dialog, componentId, closeOnBackdrop, closeOnEscape, buttons); // Show modal using native showModal() - this blocks background interaction dialog.showModal(); // Track in stack const stackItem = { componentId, dialogElement: dialog, zIndex, closeOnEscape, closeOnBackdrop }; this.modalStack.push(stackItem); // Focus management (native handles focus trapping, but we set initial focus) this.focusModal(dialog); // Return wrapper object for compatibility return { dialog: dialog, close: () => this.close(componentId), isOpen: () => dialog.open }; } /** * Setup event handlers for dialog element * @param {HTMLDialogElement} dialog - Dialog element * @param {string} componentId - Component ID * @param {boolean} closeOnBackdrop - Whether to close on backdrop click * @param {boolean} closeOnEscape - Whether to close on ESC key * @param {Array} buttons - Button configurations */ setupDialogHandlers(dialog, componentId, closeOnBackdrop, closeOnEscape, buttons) { // Native cancel event (triggered by ESC key) dialog.addEventListener('cancel', (e) => { e.preventDefault(); if (closeOnEscape) { this.close(componentId); } }); // Backdrop click handling // Note: Native doesn't have built-in backdrop click handling // We need to check if click target is the dialog element itself (backdrop) if (closeOnBackdrop) { dialog.addEventListener('click', (e) => { // If click target is the dialog element itself (not its children), it's a backdrop click if (e.target === dialog) { this.close(componentId); } }); } // Close button const closeBtn = dialog.querySelector('.modal-close'); if (closeBtn) { closeBtn.addEventListener('click', () => { this.close(componentId); }); } // Button actions dialog.querySelectorAll('[data-modal-action]').forEach((btn, index) => { btn.addEventListener('click', () => { const buttonConfig = buttons[index]; if (buttonConfig && buttonConfig.action) { // Trigger action if specified if (buttonConfig.action === 'close') { this.close(componentId); } } }); }); } /** * Close modal and remove from stack * @param {string} componentId - Component ID */ close(componentId) { const index = this.modalStack.findIndex(item => item.componentId === componentId); if (index === -1) { return; } const { dialogElement } = this.modalStack[index]; // Close dialog using native close() method if (dialogElement && dialogElement.open) { dialogElement.close(); } // Remove from DOM if (dialogElement.parentNode) { dialogElement.parentNode.removeChild(dialogElement); } // Remove from stack this.modalStack.splice(index, 1); // Focus previous modal or return focus to body if (this.modalStack.length > 0) { const previousModal = this.modalStack[this.modalStack.length - 1]; this.focusModal(previousModal.dialogElement); } else { // Return focus to previously focused element or body // Native handles this automatically, but we ensure it const activeElement = document.activeElement; if (activeElement && activeElement !== document.body) { activeElement.blur(); } document.body.focus(); } } /** * Close all modals */ closeAll() { while (this.modalStack.length > 0) { const { componentId } = this.modalStack[this.modalStack.length - 1]; this.close(componentId); } } /** * Get topmost modal * @returns {Object|null} */ getTopModal() { if (this.modalStack.length === 0) { return null; } return this.modalStack[this.modalStack.length - 1]; } /** * Check if modal is open * @param {string} componentId - Component ID * @returns {boolean} */ isOpen(componentId) { return this.modalStack.some(item => item.componentId === componentId); } /** * Focus modal element * Native handles focus trapping automatically, but we set initial focus * @param {HTMLDialogElement} dialog - Dialog element */ focusModal(dialog) { if (!dialog) { return; } // Find first focusable element const focusableSelectors = [ 'button:not([disabled])', '[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])' ].join(', '); const focusableElement = dialog.querySelector(focusableSelectors); if (focusableElement) { // Small delay to ensure dialog is fully rendered requestAnimationFrame(() => { focusableElement.focus(); }); } else { // Fallback: focus dialog itself requestAnimationFrame(() => { dialog.focus(); }); } } /** * Create modal content HTML * @param {string} title - Modal title * @param {string} content - Modal content * @param {Array} buttons - Button configurations * @returns {string} */ createModalContent(title, content, buttons) { const buttonsHtml = buttons.map((btn, index) => { const btnClass = btn.class || btn.className || 'btn-secondary'; return ``; }).join(''); return `
${title ? `` : ''} ${buttons.length > 0 ? `` : ''}
`; } /** * Cleanup all modals */ destroy() { this.closeAll(); } }