/** * @factory/ui — toast store (PURE, injectable clock). * * Framework-agnostic observable list of toasts. Time is injected: `now()` * and `schedule(fn, delayMs)` are constructor parameters, so tests drive * expiry deterministically without real timers. The React `ToastProvider` * (components/toast.tsx) is a thin renderer over this store. */ export type ToastKind = "info" | "success" | "warning" | "error"; export interface Toast { id: string; kind: ToastKind; message: string; /** Expiry timestamp (ms) per the injected clock; 0 = sticky (no auto-dismiss). */ expiresAt: number; } export interface ToastClock { now(): number; schedule(fn: () => void, delayMs: number): () => void; } export interface ToastStoreOptions { /** Default time-to-live in ms (default 5000). 0 → sticky. */ ttl?: number; clock?: ToastClock; } let nextId = 0; /** * Push/dismiss/subscribe with deterministic expiry: * - `push(kind, message, ttl?)` adds a toast, schedules its expiry via the * injected clock, and notifies subscribers. * - `dismiss(id)` removes it (and cancels its pending timer), notifies. * - Expiry is checked lazily too: `items()` never returns already-expired * toasts (safe under any clock skew / dropped timer). * - Subscribers receive the NEW snapshot array on every change. */ export class ToastStore { readonly defaultTtl: number; private readonly clock: ToastClock; private readonly toasts: Toast[] = []; private readonly timers = new Map void>(); private readonly subscribers = new Set<(items: readonly Toast[]) => void>(); constructor(options: ToastStoreOptions = {}) { this.defaultTtl = options.ttl ?? 5000; this.clock = options.clock ?? { now: () => 0, schedule: (fn) => { fn(); return () => {}; }, }; } /** Current non-expired toasts (newest last). */ items(): readonly Toast[] { const nowMs = this.clock.now(); for (let i = this.toasts.length - 1; i >= 0; i -= 1) { const t = this.toasts[i]; if (t !== undefined && t.expiresAt > 0 && t.expiresAt <= nowMs) { this.toasts.splice(i, 1); this.timers.delete(t.id); } } return this.toasts; } /** Add a toast. `ttl` overrides the store default (ms; 0 = sticky). */ push(kind: ToastKind, message: string, ttl?: number): Toast { const life = ttl ?? this.defaultTtl; const id = `toast-${nextId++}`; const expiresAt = life > 0 ? this.clock.now() + life : 0; const toast: Toast = { id, kind, message, expiresAt }; this.toasts.push(toast); if (life > 0) { this.timers.set( id, this.clock.schedule(() => this.dismiss(id), life), ); } this.notify(); return toast; } /** Remove a toast by id (no-op if unknown/expired). Returns true if removed. */ dismiss(id: string): boolean { const idx = this.toasts.findIndex((t) => t.id === id); if (idx < 0) return false; this.toasts.splice(idx, 1); const cancel = this.timers.get(id); this.timers.delete(id); cancel?.(); this.notify(); return true; } /** Subscribe to snapshot changes; returns an unsubscribe function. */ subscribe(cb: (items: readonly Toast[]) => void): () => void { this.subscribers.add(cb); return () => { this.subscribers.delete(cb); }; } private notify(): void { const snapshot = this.items(); for (const cb of this.subscribers) cb(snapshot); } }