import { warnOnce } from '../core/app-dom.js'; /** * Async Flash Messages & Loading State * Renders flash messages into the .app-toast-stack for JS-triggered notifications. * Creates the stack container on demand if it doesn't exist yet. * Provides loading cursor state management. */ /** * Set global loading state (cursor + pointer-events) * @param {boolean} loading - Whether to show loading state */ export function setLoading(loading) { if (loading) { document.body.classList.add('is-loading'); } else { document.body.classList.remove('is-loading'); } } /** * Execute an async function with loading state * @param {Function} fn - Async function to execute * @returns {Promise} - Result of the function */ export async function withLoading(fn) { setLoading(true); try { return await fn(); } finally { setLoading(false); } } const defaultTimeouts = { success: 5000, info: 6000, warning: 7000, error: 0, }; const MAX_VISIBLE_TOASTS = 5; /** * Get or create the toast stack container. * @returns {HTMLElement} */ function getToastStack() { let stack = document.querySelector('.app-toast-stack'); if (!stack) { stack = document.createElement('div'); stack.className = 'app-toast-stack'; stack.setAttribute('aria-live', 'polite'); document.body.appendChild(stack); } return stack; } /** * Dismiss a toast with slide-out animation. * @param {HTMLElement} notice */ function dismissToast(notice) { const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (prefersReducedMotion) { notice.remove(); return; } notice.classList.add('toast-dismissing'); notice.addEventListener('animationend', () => notice.remove(), { once: true }); // Fallback removal if animation doesn't fire setTimeout(() => { if (notice.parentNode) notice.remove(); }, 300); } /** * Enforce max visible toasts — dismiss oldest when limit exceeded. * @param {HTMLElement} stack */ function enforceMaxVisible(stack) { const notices = stack.querySelectorAll('.notice:not(.toast-dismissing)'); if (notices.length > MAX_VISIBLE_TOASTS) { for (let i = 0; i < notices.length - MAX_VISIBLE_TOASTS; i++) { dismissToast(notices[i]); } } } /** * Show an async flash message * @param {string} type - Message type: 'success', 'info', 'warning', 'error' * @param {string} message - The message text * @param {number} [timeout] - Auto-dismiss timeout in ms (0 = no auto-dismiss) * @returns {HTMLElement|null} */ export function showAsyncFlash(type, message, timeout) { const stack = getToastStack(); const effectiveTimeout = timeout ?? defaultTimeouts[type] ?? 5000; const notice = document.createElement('div'); notice.className = 'notice'; notice.setAttribute('data-variant', type); const isAssertive = type === 'error' || type === 'warning'; notice.setAttribute('role', isAssertive ? 'alert' : 'status'); notice.setAttribute('aria-live', isAssertive ? 'assertive' : 'polite'); notice.setAttribute('aria-atomic', 'true'); const messageSpan = document.createElement('span'); messageSpan.textContent = message; notice.appendChild(messageSpan); const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.setAttribute('aria-label', 'Dismiss'); closeButton.innerHTML = ''; closeButton.addEventListener('click', () => dismissToast(notice)); notice.appendChild(closeButton); stack.appendChild(notice); enforceMaxVisible(stack); if (effectiveTimeout > 0) { notice.style.setProperty('--flash-timeout', `${effectiveTimeout}ms`); notice.classList.add('flash-timed'); let timer = setTimeout(() => dismissToast(notice), effectiveTimeout); // Pause timer on hover notice.addEventListener('mouseenter', () => { clearTimeout(timer); }); notice.addEventListener('mouseleave', () => { timer = setTimeout(() => dismissToast(notice), 1000); }); } return notice; } /** * Clear all async flash messages */ export function clearAsyncFlash() { const stack = document.querySelector('.app-toast-stack'); if (stack) { stack.querySelectorAll('.notice').forEach((n) => dismissToast(n)); } }