Spaces:
Running
Running
| 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<Toast['variant'], typeof Info> = { | |
| success: CheckCircle2, | |
| info: Info, | |
| warning: AlertTriangle, | |
| error: XCircle, | |
| } | |
| const COLORS: Record<Toast['variant'], string> = { | |
| 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<Toast[]>([]) | |
| const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>()) | |
| const recent = useRef(new Map<string, { id: string; at: number }>()) | |
| 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<ToastContextValue['push']>( | |
| (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<ToastContextValue>( | |
| () => ({ 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 ( | |
| <div | |
| key={t.id} | |
| className={`pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-lg border p-3 shadow-lg backdrop-blur-md ${COLORS[t.variant]}`} | |
| > | |
| <Icon size={16} className="mt-0.5 shrink-0" aria-hidden="true" /> | |
| <div className="min-w-0 flex-1 text-sm"> | |
| {t.title && <div className="font-medium">{t.title}</div>} | |
| <div className="mt-0.5 break-words">{t.message}</div> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => dismiss(t.id)} | |
| className="-mr-1 -mt-1 rounded p-1 text-current/70 hover:text-current focus:outline-none focus:ring-2 focus:ring-current" | |
| aria-label="Dismiss notification" | |
| > | |
| <X size={14} aria-hidden="true" /> | |
| </button> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <ToastContext.Provider value={value}> | |
| {children} | |
| <div | |
| role="region" | |
| aria-label="Notifications" | |
| className="pointer-events-none fixed inset-x-0 top-4 z-[60] flex flex-col items-center gap-2 px-4 sm:right-4 sm:left-auto sm:items-end" | |
| > | |
| <div aria-live="polite" aria-atomic="false" className="flex flex-col items-stretch gap-2"> | |
| {polite.map(renderToast)} | |
| </div> | |
| <div aria-live="assertive" aria-atomic="false" className="flex flex-col items-stretch gap-2"> | |
| {assertive.map(renderToast)} | |
| </div> | |
| </div> | |
| </ToastContext.Provider> | |
| ) | |
| } | |