forked from fa/breadcrumb-the-shire
Notices used to be styled only inside the toast stack — every inline .notice on the login page, auth pages and admin edit screens fell back to browser defaults and looked unstyled. The Stripe-style toast redesign (icon pill + neutral text + soft card surface) now lives on the base .notice rule, and .app-toast-stack adds the slide-in animation, soft shadow, dismiss button and progress bar on top. Inline notices auto-render the variant icon via a ::before pseudo using the Bootstrap Icons codepoints, so all 30+ existing call sites get the new look without markup changes. Toasts opt out of ::before via :has(> .notice-icon) and keep their explicit icon span. The previous app-flash.phtml partial is folded into app-toast-stack.phtml (both create the same .app-toast-stack container — having both mounted made two stacks fight for the same fixed corner). default/login/page templates now mount the unified partial; the architecture contract is updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
174 lines
5.0 KiB
JavaScript
174 lines
5.0 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;
|
|
|
|
const VARIANT_ICONS = {
|
|
success: 'bi-check-circle-fill',
|
|
error: 'bi-x-octagon-fill',
|
|
warning: 'bi-exclamation-triangle-fill',
|
|
info: 'bi-info-circle-fill',
|
|
};
|
|
|
|
/**
|
|
* 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 iconSpan = document.createElement('span');
|
|
iconSpan.className = 'notice-icon';
|
|
iconSpan.setAttribute('aria-hidden', 'true');
|
|
const iconElement = document.createElement('i');
|
|
iconElement.className = `bi ${VARIANT_ICONS[type] || VARIANT_ICONS.info}`;
|
|
iconSpan.appendChild(iconElement);
|
|
notice.appendChild(iconSpan);
|
|
|
|
const messageSpan = document.createElement('span');
|
|
messageSpan.className = 'notice-content';
|
|
messageSpan.textContent = message;
|
|
notice.appendChild(messageSpan);
|
|
|
|
const closeButton = document.createElement('button');
|
|
closeButton.type = 'button';
|
|
closeButton.className = 'notice-dismiss';
|
|
closeButton.setAttribute('aria-label', 'Dismiss');
|
|
const closeIcon = document.createElement('i');
|
|
closeIcon.className = 'bi bi-x-lg';
|
|
closeButton.appendChild(closeIcon);
|
|
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));
|
|
}
|
|
}
|