UNPKG

gentelella

Version:

Gentelella v4 — free admin template. 60 pages, 20 chart variants, fully interactive inbox & kanban, live theme generator, component playground, PWA-ready. Vite 8, vanilla JS, no Bootstrap, no jQuery.

144 lines (120 loc) 5.5 kB
// Reusable modal dialog. One open at a time; backdrop click, Escape, and // the close button all dismiss. Focus is moved into the dialog on open and // restored on close. Tab is trapped inside while open. let openBackdrop = null; let previousFocus = null; let onCloseHook = null; const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; export function isModalOpen() { return !!openBackdrop; } export function closeModal({ skipHook = false } = {}) { if (!openBackdrop) {return;} const el = openBackdrop; el.classList.remove('show'); document.body.classList.remove('modal-open'); const cleanup = () => { if (el.isConnected) {el.remove();} }; el.addEventListener('transitionend', cleanup, { once: true }); setTimeout(cleanup, 280); const hook = onCloseHook; const focusBack = previousFocus; openBackdrop = null; onCloseHook = null; previousFocus = null; if (focusBack && typeof focusBack.focus === 'function') {focusBack.focus();} if (!skipHook && typeof hook === 'function') {hook();} } /** * @typedef {Object} ModalAction * @property {string} label * @property {'primary' | 'outline' | 'danger' | 'ghost'} [variant] * @property {(ctx: { dialog: HTMLElement, body: HTMLElement, close: () => void }) => void} [action] * @property {boolean} [closeOnAction] If true (default), the modal closes after `action` runs. */ /** * Show a centered modal dialog with focus trap, ESC + backdrop dismiss. * @param {Object} [opts] * @param {string} [opts.title] * @param {string | HTMLElement} [opts.body] HTML string (assigned via innerHTML) or element to mount. * @param {ModalAction[]} [opts.actions] Footer buttons. * @param {'sm' | 'md' | 'lg'} [opts.size] * @param {() => void} [opts.onClose] Fires after the modal is dismissed (any reason). * @returns {{ dialog: HTMLElement, body: HTMLElement, close: () => void }} */ export function showModal({ title, body = '', actions = [], size = 'md', onClose } = {}) { closeModal({ skipHook: true }); const backdrop = document.createElement('div'); backdrop.className = 'modal-backdrop'; const dialog = document.createElement('div'); dialog.className = `modal-dialog modal-${size}`; dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); if (title) {dialog.setAttribute('aria-label', title);} const header = document.createElement('div'); header.className = 'modal-header'; header.innerHTML = ` <h2 class="modal-title">${title || ''}</h2> <button type="button" class="modal-close" aria-label="Close"> <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M3 3l8 8M11 3l-8 8"/></svg> </button> `; const bodyEl = document.createElement('div'); bodyEl.className = 'modal-body'; if (body instanceof HTMLElement) {bodyEl.appendChild(body);} else {bodyEl.innerHTML = body;} dialog.appendChild(header); dialog.appendChild(bodyEl); let footer = null; if (actions.length) { footer = document.createElement('div'); footer.className = 'modal-footer'; actions.forEach((a) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = `btn btn-${a.variant || 'outline'}`; btn.textContent = a.label; btn.addEventListener('click', () => { const ctx = { dialog, body: bodyEl, close: () => closeModal() }; const result = typeof a.action === 'function' ? a.action(ctx) : null; // Action can return false to keep the modal open (e.g. validation failed). if (result === false) {return;} if (a.closeOnAction !== false) {closeModal();} }); footer.appendChild(btn); }); dialog.appendChild(footer); } backdrop.appendChild(dialog); document.body.appendChild(backdrop); document.body.classList.add('modal-open'); // Animation in: requestAnimationFrame so the .show class transition fires. requestAnimationFrame(() => backdrop.classList.add('show')); // Close handlers header.querySelector('.modal-close').addEventListener('click', () => closeModal()); backdrop.addEventListener('click', (e) => { if (e.target === backdrop) {closeModal();} }); // Focus trap dialog.addEventListener('keydown', (e) => { if (e.key !== 'Tab') {return;} const f = dialog.querySelectorAll(FOCUSABLE); if (!f.length) {return;} const first = f[0]; const last = f[f.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }); // Track focus for restoration previousFocus = document.activeElement; openBackdrop = backdrop; onCloseHook = onClose; // Initial focus: first focusable in body, else first action, else close button const firstInBody = bodyEl.querySelector(FOCUSABLE); if (firstInBody) {firstInBody.focus();} else if (footer && footer.querySelector('.btn-primary')) {footer.querySelector('.btn-primary').focus();} else {header.querySelector('.modal-close').focus();} return { dialog, body: bodyEl, close: () => closeModal() }; } // Global Escape handler — registered once. document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openBackdrop) { e.stopPropagation(); closeModal(); } });