/** * @file toast.ts * @description App-wide toast queue: `toast.success(msg)` / `toast.error(msg)` * from anywhere, rendered once by `components/Toaster.tsx` inside live regions. * A success fades after a few seconds; an error stays until dismissed, because * a message that vanishes before it is read is no message at all (WCAG 2.2.1). * @author Nguyễn Ngọc Trí Vĩ */ export type ToastKind = "success" | "error" | "info"; export interface ToastItem { id: number; kind: ToastKind; message: string; } const SUCCESS_MS = 4000; let items: ToastItem[] = []; let nextId = 1; const listeners = new Set<(items: ToastItem[]) => void>(); function emit() { for (const l of listeners) l(items); } /** Remove one toast. */ export function dismissToast(id: number): void { items = items.filter((t) => t.id !== id); emit(); } function push(kind: ToastKind, message: string): number { const id = nextId++; // Same text twice in a row (a double click, a retry) shows once. items = [...items.filter((t) => t.message !== message), { id, kind, message }].slice(-4); emit(); if (kind !== "error") window.setTimeout(() => dismissToast(id), SUCCESS_MS); return id; } /** Subscribe to the queue; returns the unsubscribe. */ export function subscribeToasts(fn: (items: ToastItem[]) => void): () => void { listeners.add(fn); fn(items); return () => { listeners.delete(fn); }; } export const toast = { success: (message: string) => push("success", message), info: (message: string) => push("info", message), error: (message: unknown) => push("error", message instanceof Error ? message.message : String(message)), };