import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import { CheckCircle2, Info, AlertTriangle, XCircle, X } from 'lucide-react' import { ToastContext, type Toast, type ToastContextValue } from './toast' const DEFAULT_DURATION = 4000 // H6: collapse identical toasts that fire in rapid succession (e.g. a bulk // delete that errors on every file). If the same (variant + title + message) // appears within this window we drop the new push and return the live id. const DEDUPE_WINDOW_MS = 2_000 const ICONS: Record = { success: CheckCircle2, info: Info, warning: AlertTriangle, error: XCircle, } const COLORS: Record = { success: 'border-emerald-300/60 bg-emerald-50 text-emerald-800 dark:border-emerald-400/30 dark:bg-emerald-500/10 dark:text-emerald-200', info: 'border-slate-300/60 bg-white text-slate-800 dark:border-white/10 dark:bg-slate-900 dark:text-slate-100', warning: 'border-amber-300/60 bg-amber-50 text-amber-800 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-200', error: 'border-rose-300/60 bg-rose-50 text-rose-800 dark:border-rose-400/30 dark:bg-rose-500/10 dark:text-rose-200', } export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]) const timers = useRef(new Map>()) const recent = useRef(new Map()) const dismiss = useCallback((id: string) => { setToasts((prev) => prev.filter((t) => t.id !== id)) const timer = timers.current.get(id) if (timer) { clearTimeout(timer) timers.current.delete(id) } for (const [key, entry] of recent.current) { if (entry.id === id) recent.current.delete(key) } }, []) const push = useCallback( (toast) => { const now = Date.now() const sig = `${toast.variant}|${toast.title ?? ''}|${toast.message}` const prior = recent.current.get(sig) if (prior && now - prior.at < DEDUPE_WINDOW_MS) { // Refresh timestamp so a steady stream of dupes keeps suppressing. recent.current.set(sig, { id: prior.id, at: now }) return prior.id } const id = `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}` const next: Toast = { id, ...toast } setToasts((prev) => [...prev, next]) const duration = toast.durationMs ?? DEFAULT_DURATION if (duration > 0) { const timer = setTimeout(() => dismiss(id), duration) timers.current.set(id, timer) } recent.current.set(sig, { id, at: now }) return id }, [dismiss], ) const clear = useCallback(() => { timers.current.forEach((t) => clearTimeout(t)) timers.current.clear() recent.current.clear() setToasts([]) }, []) // Cleanup on unmount. Snapshot the current Map so the cleanup function // doesn't reach back into the (possibly-different) ref at unmount time. useEffect(() => { const snapshot = timers.current return () => { snapshot.forEach((t) => clearTimeout(t)) snapshot.clear() } }, []) const value = useMemo( () => ({ toasts, push, dismiss, clear }), [toasts, push, dismiss, clear], ) // Split toasts into two live regions: errors go to an `assertive` // region so screen readers announce them immediately, everything else // goes to a `polite` region. Announcing through the live region means // we don't need `role="alert" / role="status"` on each toast — that // would cause nested live regions and double announcements on JAWS. const polite = toasts.filter((t) => t.variant !== 'error') const assertive = toasts.filter((t) => t.variant === 'error') const renderToast = (t: Toast) => { const Icon = ICONS[t.variant] return (
) } return ( {children}
{polite.map(renderToast)}
{assertive.map(renderToast)}
) }