// Toast notifications ported from mnw.js:44-68. // // One implementation of a toast: `initToasts` renders the ordinary one and // `showErrorToast` the one with a Retry button. The error toast was built by // hand in `htmx-glue.ts` until `2c8cf51c` and lived 6000ms because it carries a // button; both toasts are `Intent::Dismiss` now, since one duration per intent // is what `makeover-timing` rules and a divergence there is a bug report rather // than an axis. import { dismissMs, fadeMs } from './timing.ts'; /** Maximum simultaneously-visible toasts. Beyond this the oldest drops first so * a burst of HTMX errors can't bury the viewport. */ const TOAST_MAX_VISIBLE = 5; interface ToastDetail { message?: string; type?: string; } /** Let `toast` live `Intent::Dismiss`, then fade it out and remove it. * * The fade is `Motion::Fade`, which is also what the `.fade-out` transition in * the stylesheet reads, so the node leaves when the animation ends rather than * during it. Reduced motion zeroes the fade and leaves the wait alone: a * reader asking for less motion has not asked for a notice to leave early. */ function autoDismiss(toast: HTMLElement): void { setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), fadeMs()); }, dismissMs()); } /** Put `toast` on the page and start its dismissal, dropping the oldest first * so a burst can't bury the viewport. Both kinds of toast arrive here. */ function mount(toast: HTMLElement): void { const host = document.getElementById('notifications'); if (!host) return; while (host.childElementCount >= TOAST_MAX_VISIBLE) { host.firstElementChild?.remove(); } host.appendChild(toast); autoDismiss(toast); } /** Render an error toast carrying a Retry button and a dismiss control. * * Called by the HTMX response-error listener, which owns deciding what the * message says and what retrying means; building the element is this file's * job, like every other toast on the page. */ export function showErrorToast(text: string, retry: () => void): void { const toast = document.createElement('div'); toast.className = 'toast toast-error'; const msg = document.createElement('span'); msg.textContent = text; toast.appendChild(msg); const retryBtn = document.createElement('button'); retryBtn.textContent = 'Retry'; retryBtn.className = 'toast-retry-btn'; retryBtn.onclick = () => { toast.remove(); retry(); }; toast.appendChild(retryBtn); const closeBtn = document.createElement('button'); closeBtn.className = 'toast-dismiss'; closeBtn.textContent = '×'; closeBtn.setAttribute('aria-label', 'Dismiss'); closeBtn.onclick = () => toast.remove(); toast.appendChild(closeBtn); mount(toast); } /** Fire a toast. Rendered by the delegated listener installed by `initToasts`. */ export function showToast(message: string, type = 'error'): void { document.body.dispatchEvent(new CustomEvent('showToast', { detail: { message, type } })); } /** Install the toast renderer (caps the stack, auto-dismisses). Call once at * startup. Ported from mnw.js:47. */ export function initToasts(): void { document.body.addEventListener('showToast', (evt) => { const detail = (evt as CustomEvent).detail; const toast = document.createElement('div'); toast.className = 'toast toast-' + (detail.type || 'info'); toast.textContent = detail.message || 'Action completed'; mount(toast); }); }