Skip to main content

max / makenotwork

3.4 KB · 94 lines History Blame Raw
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