export type ToastKind = "success" | "info" | "warning" | "error"; export interface Toast { id: number; kind: ToastKind; title: string; subtitle?: string; } /** Success, info and warning toasts leave by themselves; errors stay until closed. */ export const AUTO_DISMISS_MS = 5000; /** Oldest toasts are dropped beyond this, so a failing loop cannot fill the screen. */ export const MAX_TOASTS = 5; type TimerId = unknown; export interface ToastStoreOptions { setTimer?: (fn: () => void, ms: number) => TimerId; clearTimer?: (id: TimerId) => void; } export interface ToastStore { add: (kind: ToastKind, title: string, subtitle?: string) => number; dismiss: (id: number) => void; clear: () => void; subscribe: (listener: () => void) => () => void; getSnapshot: () => readonly Toast[]; } /** A tiny external store (useSyncExternalStore-compatible); the timers are injectable for tests. */ export function createToastStore(options: ToastStoreOptions = {}): ToastStore { const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms)); const clearTimer = options.clearTimer ?? ((id) => clearTimeout(id as ReturnType)); let toasts: readonly Toast[] = []; let nextId = 1; const timers = new Map(); const listeners = new Set<() => void>(); const emit = () => listeners.forEach((l) => l()); const drop = (id: number) => { const timer = timers.get(id); if (timer !== undefined) { clearTimer(timer); timers.delete(id); } }; const dismiss = (id: number) => { drop(id); if (!toasts.some((t) => t.id === id)) return; toasts = toasts.filter((t) => t.id !== id); emit(); }; return { add(kind, title, subtitle) { const id = nextId++; toasts = [...toasts, { id, kind, title, subtitle }]; while (toasts.length > MAX_TOASTS) { drop(toasts[0].id); toasts = toasts.slice(1); } if (kind !== "error") timers.set(id, setTimer(() => dismiss(id), AUTO_DISMISS_MS)); emit(); return id; }, dismiss, clear() { timers.forEach((t) => clearTimer(t)); timers.clear(); toasts = []; emit(); }, subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, getSnapshot: () => toasts, }; }