/** * Generic modal helpers for the dashboard. * Handles open/close animations, error display, backdrop click, and Escape key. * Depends on: core/utils.js ($), core/navigation.js (navigateTo) */ let _modalPreviousFocus = null; /** * Open a modal by adding the `open` class and focusing the first focusable element. * Stores the previously focused element for restore on close. Sets ARIA attributes. * @param {string} id - The modal backdrop element ID. */ function openModal(id) { const el = $(id); if (!el) return; _modalPreviousFocus = document.activeElement; el.classList.add('open'); el.setAttribute('aria-hidden', 'false'); el.setAttribute('aria-modal', 'true'); el.setAttribute('role', 'dialog'); const modalTitle = el.querySelector('.modal-title'); if (modalTitle && modalTitle.id) el.setAttribute('aria-labelledby', modalTitle.id); else if (modalTitle && !modalTitle.id) { const tid = 'modal-title-' + id; modalTitle.id = tid; el.setAttribute('aria-labelledby', tid); } setTimeout(() => { const focusable = el.querySelector('input:not([type="checkbox"]):not([disabled]), button:not([disabled]), [tabindex="0"]'); if (focusable) focusable.focus(); }, 50); } /** * Close a modal by removing the `open` class and clearing any error messages. * Restores focus to the element that had focus before the modal opened. * @param {string} id - The modal backdrop element ID. */ function closeModal(id) { const el = $(id); if (!el) return; el.classList.remove('open'); el.setAttribute('aria-hidden', 'true'); el.removeAttribute('aria-modal'); el.removeAttribute('role'); el.removeAttribute('aria-labelledby'); el.querySelectorAll('.modal-error').forEach(e => { e.style.display = 'none'; e.textContent = ''; }); if (_modalPreviousFocus && typeof _modalPreviousFocus.focus === 'function') { _modalPreviousFocus.focus(); _modalPreviousFocus = null; } } /** * Display an error message inside a modal's error element. * @param {string} modalId - Unused; kept for API symmetry. * @param {string} errorId - ID of the error display element. * @param {string} msg - Error message text. */ function showModalError(modalId, errorId, msg) { const el = $(errorId); if (!el) return; el.textContent = msg; el.style.display = 'block'; el.setAttribute('role', 'alert'); } // Close modals on backdrop click or close button document.addEventListener('click', (e) => { const closeBtn = e.target.closest('[data-close-modal]'); if (closeBtn) { closeModal(closeBtn.dataset.closeModal); return; } if (e.target.classList.contains('modal-backdrop')) { closeModal(e.target.id); } const pageLink = e.target.closest('[data-page-link]'); if (pageLink) { navigateTo(pageLink.dataset.pageLink); } }); // Escape key closes topmost open modal; Tab trap focus inside modal document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { const open = document.querySelector('.modal-backdrop.open'); if (open) closeModal(open.id); return; } if (e.key !== 'Tab') return; const open = document.querySelector('.modal-backdrop.open'); if (!open) return; const focusable = open.querySelectorAll('input:not([disabled]), button:not([disabled]), select:not([disabled]), [tabindex="0"]'); const first = focusable[0]; const last = focusable[focusable.length - 1]; if (!first || !last) return; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } });