(function () { 'use strict'; let csrfToken = ''; async function fetchCsrfToken() { const res = await fetch('/api/csrf-token', { credentials: 'same-origin' }); if (!res.ok) throw new Error('Failed to fetch CSRF token'); const data = await res.json(); csrfToken = data.csrfToken; return csrfToken; } function csrfHeaders(base = {}) { return { ...base, 'x-csrf-token': csrfToken }; } function showToast(message, type = 'success') { const isError = type === 'error'; const timeoutMs = isError ? 6000 : 3500; const toast = document.createElement('div'); toast.className = `toast toast-${type}`; // status (polite) for info/success/warning; alert (assertive) for errors. toast.setAttribute('role', isError ? 'alert' : 'status'); toast.setAttribute('aria-live', isError ? 'assertive' : 'polite'); toast.setAttribute('aria-atomic', 'true'); const messageEl = document.createElement('span'); messageEl.className = 'toast-message'; messageEl.textContent = message; toast.appendChild(messageEl); const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'toast-close'; closeBtn.setAttribute('aria-label', 'Dismiss'); closeBtn.textContent = '×'; toast.appendChild(closeBtn); // Auto-dismiss with hover-to-pause. let remaining = timeoutMs; let startedAt = 0; let timer = null; function dismiss() { if (timer) clearTimeout(timer); timer = null; if (toast.parentNode) toast.remove(); } function scheduleDismiss() { startedAt = Date.now(); timer = setTimeout(dismiss, remaining); } function pauseDismiss() { if (!timer) return; clearTimeout(timer); timer = null; remaining -= Date.now() - startedAt; } closeBtn.addEventListener('click', dismiss); toast.addEventListener('mouseenter', pauseDismiss); toast.addEventListener('mouseleave', scheduleDismiss); toast.addEventListener('focusin', pauseDismiss); toast.addEventListener('focusout', scheduleDismiss); document.getElementById('toast-container').appendChild(toast); scheduleDismiss(); } function formatLocalDateTime(d) { const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } const MOBILE_BREAKPOINT = 900; function isMobileViewport() { return window.innerWidth <= MOBILE_BREAKPOINT; } function setSidebarOpen(open) { document.body.classList.toggle('sidebar-open', open); const toggle = document.getElementById('menu-toggle'); if (toggle) toggle.setAttribute('aria-expanded', String(open)); } // ---------- Modal focus management ---------- const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])' ].join(','); function getFocusableElements(container) { return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)) .filter(el => !el.hasAttribute('inert') && el.offsetParent !== null); } // Per-modal teardown state; WeakMap keys by modal element to support many. const modalState = new WeakMap(); function openModal(modalEl, options = {}) { if (!modalEl) return; if (modalState.has(modalEl)) return; // already open const previouslyFocused = document.activeElement; modalEl.classList.remove('hidden'); // Find something to focus. Prefer options.initialFocus, then first field, then modal card. const card = modalEl.querySelector('.modal-card') || modalEl; if (!card.hasAttribute('tabindex')) card.setAttribute('tabindex', '-1'); const initial = options.initialFocus ? modalEl.querySelector(options.initialFocus) : getFocusableElements(modalEl)[0] || card; initial?.focus(); const onKeydown = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeModal(modalEl); options.onClose?.(); return; } if (e.key !== 'Tab') return; const focusables = getFocusableElements(modalEl); if (focusables.length === 0) { e.preventDefault(); card.focus(); return; } const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; modalEl.addEventListener('keydown', onKeydown); modalState.set(modalEl, { previouslyFocused, onKeydown }); } function closeModal(modalEl) { if (!modalEl) return; const state = modalState.get(modalEl); modalEl.classList.add('hidden'); if (!state) return; modalEl.removeEventListener('keydown', state.onKeydown); modalState.delete(modalEl); const target = state.previouslyFocused; if (target && typeof target.focus === 'function' && document.body.contains(target)) { target.focus(); } else { document.body.focus?.(); } } window.Util = { fetchCsrfToken, csrfHeaders, showToast, formatLocalDateTime, isMobileViewport, setSidebarOpen, openModal, closeModal, getFocusableElements, MOBILE_BREAKPOINT }; })();