Files
breadcrumb-the-shire/web/js/components/app-async-flash.js
fs 2429588256 refactor(ui): move toast stack chrome to a server-rendered partial
Mirrors the detail-drawer move: the .app-toast-stack container is no
longer lazy-created in JS on first toast, instead it lives in
templates/partials/app-toast-stack.phtml and is mounted once in
default.phtml alongside the confirm dialog. The aria-live attribute now
sits in the SSR markup so screen readers register the live region from
page load, not from the first toast.

getToastStack() drops to a plain querySelector and warns via warnOnce
when the partial is missing instead of constructing a fallback container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:13:55 +02:00

155 lines
4.4 KiB
JavaScript

import { warnOnce } from '../core/app-dom.js';
/**
* Async Flash Messages & Loading State
*
* Renders flash messages into the .app-toast-stack for JS-triggered
* notifications. The stack container is rendered server-side by
* templates/partials/app-toast-stack.phtml (mounted once in default.phtml);
* this module only appends/removes toasts inside it.
*
* 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<any>} - 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;
/**
* Resolve the server-rendered toast stack container.
* Returns null if the partial is missing — callers must handle that case.
* @returns {HTMLElement|null}
*/
function getToastStack() {
const stack = document.querySelector('.app-toast-stack');
return stack instanceof HTMLElement ? stack : null;
}
/**
* 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();
if (!stack) {
warnOnce('UI_EL_MISSING', 'Missing .app-toast-stack — partial not mounted?', {
module: 'async-flash',
});
return null;
}
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 = '<i class="bi bi-x-lg"></i>';
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));
}
}