| 1 |
// Toast notifications ported from mnw.js:44-68. |
| 2 |
// |
| 3 |
// One implementation of a toast: `initToasts` renders the ordinary one and |
| 4 |
// `showErrorToast` the one with a Retry button. The error toast was built by |
| 5 |
// hand in `htmx-glue.ts` until `2c8cf51c` and lived 6000ms because it carries a |
| 6 |
// button; both toasts are `Intent::Dismiss` now, since one duration per intent |
| 7 |
// is what `makeover-timing` rules and a divergence there is a bug report rather |
| 8 |
// than an axis. |
| 9 |
|
| 10 |
import { dismissMs, fadeMs } from './timing.ts'; |
| 11 |
|
| 12 |
/** Maximum simultaneously-visible toasts. Beyond this the oldest drops first so |
| 13 |
* a burst of HTMX errors can't bury the viewport. */ |
| 14 |
const TOAST_MAX_VISIBLE = 5; |
| 15 |
|
| 16 |
interface ToastDetail { |
| 17 |
message?: string; |
| 18 |
type?: string; |
| 19 |
} |
| 20 |
|
| 21 |
/** Let `toast` live `Intent::Dismiss`, then fade it out and remove it. |
| 22 |
* |
| 23 |
* The fade is `Motion::Fade`, which is also what the `.fade-out` transition in |
| 24 |
* the stylesheet reads, so the node leaves when the animation ends rather than |
| 25 |
* during it. Reduced motion zeroes the fade and leaves the wait alone: a |
| 26 |
* reader asking for less motion has not asked for a notice to leave early. */ |
| 27 |
function autoDismiss(toast: HTMLElement): void { |
| 28 |
setTimeout(() => { |
| 29 |
toast.classList.add('fade-out'); |
| 30 |
setTimeout(() => toast.remove(), fadeMs()); |
| 31 |
}, dismissMs()); |
| 32 |
} |
| 33 |
|
| 34 |
/** Put `toast` on the page and start its dismissal, dropping the oldest first |
| 35 |
* so a burst can't bury the viewport. Both kinds of toast arrive here. */ |
| 36 |
function mount(toast: HTMLElement): void { |
| 37 |
const host = document.getElementById('notifications'); |
| 38 |
if (!host) return; |
| 39 |
while (host.childElementCount >= TOAST_MAX_VISIBLE) { |
| 40 |
host.firstElementChild?.remove(); |
| 41 |
} |
| 42 |
host.appendChild(toast); |
| 43 |
autoDismiss(toast); |
| 44 |
} |
| 45 |
|
| 46 |
/** Render an error toast carrying a Retry button and a dismiss control. |
| 47 |
* |
| 48 |
* Called by the HTMX response-error listener, which owns deciding what the |
| 49 |
* message says and what retrying means; building the element is this file's |
| 50 |
* job, like every other toast on the page. */ |
| 51 |
export function showErrorToast(text: string, retry: () => void): void { |
| 52 |
const toast = document.createElement('div'); |
| 53 |
toast.className = 'toast toast-error'; |
| 54 |
|
| 55 |
const msg = document.createElement('span'); |
| 56 |
msg.textContent = text; |
| 57 |
toast.appendChild(msg); |
| 58 |
|
| 59 |
const retryBtn = document.createElement('button'); |
| 60 |
retryBtn.textContent = 'Retry'; |
| 61 |
retryBtn.className = 'toast-retry-btn'; |
| 62 |
retryBtn.onclick = () => { |
| 63 |
toast.remove(); |
| 64 |
retry(); |
| 65 |
}; |
| 66 |
toast.appendChild(retryBtn); |
| 67 |
|
| 68 |
const closeBtn = document.createElement('button'); |
| 69 |
closeBtn.className = 'toast-dismiss'; |
| 70 |
closeBtn.textContent = '×'; |
| 71 |
closeBtn.setAttribute('aria-label', 'Dismiss'); |
| 72 |
closeBtn.onclick = () => toast.remove(); |
| 73 |
toast.appendChild(closeBtn); |
| 74 |
|
| 75 |
mount(toast); |
| 76 |
} |
| 77 |
|
| 78 |
/** Fire a toast. Rendered by the delegated listener installed by `initToasts`. */ |
| 79 |
export function showToast(message: string, type = 'error'): void { |
| 80 |
document.body.dispatchEvent(new CustomEvent<ToastDetail>('showToast', { detail: { message, type } })); |
| 81 |
} |
| 82 |
|
| 83 |
/** Install the toast renderer (caps the stack, auto-dismisses). Call once at |
| 84 |
* startup. Ported from mnw.js:47. */ |
| 85 |
export function initToasts(): void { |
| 86 |
document.body.addEventListener('showToast', (evt) => { |
| 87 |
const detail = (evt as CustomEvent<ToastDetail>).detail; |
| 88 |
const toast = document.createElement('div'); |
| 89 |
toast.className = 'toast toast-' + (detail.type || 'info'); |
| 90 |
toast.textContent = detail.message || 'Action completed'; |
| 91 |
mount(toast); |
| 92 |
}); |
| 93 |
} |
| 94 |
|