| 1 |
// Toast notifications ported from mnw.js:44-68. |
| 2 |
|
| 3 |
/** Maximum simultaneously-visible toasts. Beyond this the oldest drops first so |
| 4 |
* a burst of HTMX errors can't bury the viewport. */ |
| 5 |
const TOAST_MAX_VISIBLE = 5; |
| 6 |
|
| 7 |
interface ToastDetail { |
| 8 |
message?: string; |
| 9 |
type?: string; |
| 10 |
} |
| 11 |
|
| 12 |
/** How long the fade-out animation runs before the node is removed. Matches the |
| 13 |
* `.fade-out` transition in the stylesheet, so shortening it here drops the |
| 14 |
* toast mid-animation. */ |
| 15 |
const TOAST_FADE_MS = 300; |
| 16 |
|
| 17 |
/** Fade `toast` out after `lifetimeMs`, then remove it. |
| 18 |
* |
| 19 |
* Extracted because `initHtmxGlue`'s error toast had its own verbatim copy of |
| 20 |
* the same two nested timers. The lifetime stays the caller's business: this |
| 21 |
* renderer's toasts live 3000ms and an error toast lives 6000ms, and which of |
| 22 |
* those is right is a question for the semantic-timing work rather than for a |
| 23 |
* helper that only exists to stop the dismissal being written twice. */ |
| 24 |
export function autoDismiss(toast: HTMLElement, lifetimeMs: number): void { |
| 25 |
setTimeout(() => { |
| 26 |
toast.classList.add('fade-out'); |
| 27 |
setTimeout(() => toast.remove(), TOAST_FADE_MS); |
| 28 |
}, lifetimeMs); |
| 29 |
} |
| 30 |
|
| 31 |
/** Fire a toast. Rendered by the delegated listener installed by `initToasts`. */ |
| 32 |
export function showToast(message: string, type = 'error'): void { |
| 33 |
document.body.dispatchEvent(new CustomEvent<ToastDetail>('showToast', { detail: { message, type } })); |
| 34 |
} |
| 35 |
|
| 36 |
/** Install the toast renderer (caps the stack, auto-dismisses). Call once at |
| 37 |
* startup. Ported from mnw.js:47. */ |
| 38 |
export function initToasts(): void { |
| 39 |
document.body.addEventListener('showToast', (evt) => { |
| 40 |
const detail = (evt as CustomEvent<ToastDetail>).detail; |
| 41 |
const container = document.getElementById('notifications'); |
| 42 |
if (!container) return; |
| 43 |
while (container.childElementCount >= TOAST_MAX_VISIBLE) { |
| 44 |
container.firstElementChild?.remove(); |
| 45 |
} |
| 46 |
const toast = document.createElement('div'); |
| 47 |
toast.className = 'toast toast-' + (detail.type || 'info'); |
| 48 |
toast.textContent = detail.message || 'Action completed'; |
| 49 |
container.appendChild(toast); |
| 50 |
autoDismiss(toast, 3000); |
| 51 |
}); |
| 52 |
} |
| 53 |
|