File
Blob: src/client/toast/toast-provider.tsx
| 1 | import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { AlertCircle, CheckCircle2, Info, X } from "lucide-react"; |
| 3 | import { ToastContext, type Toast, type ToastContextValue, type ToastTone } from "@/client/toast/toast-context"; |
| 4 | |
| 5 | const TOAST_TIMEOUT_MS = 4000; |
| 6 | |
| 7 | const TONE_STYLES: Record< |
| 8 | ToastTone, |
| 9 | { |
| 10 | panel: string; |
| 11 | icon: typeof CheckCircle2; |
| 12 | } |
| 13 | > = { |
| 14 | success: { |
| 15 | panel: "border-emerald-500/20 bg-emerald-500/10 text-emerald-100", |
| 16 | icon: CheckCircle2, |
| 17 | }, |
| 18 | error: { |
| 19 | panel: "border-red-500/20 bg-red-500/10 text-red-100", |
| 20 | icon: AlertCircle, |
| 21 | }, |
| 22 | info: { |
| 23 | panel: "border-accent-500/20 bg-accent-500/10 text-accent-100", |
| 24 | icon: Info, |
| 25 | }, |
| 26 | }; |
| 27 | |
| 28 | const createToastId = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; |
| 29 | |
| 30 | export const ToastProvider = ({ children }: { children: ReactNode }) => { |
| 31 | const [toasts, setToasts] = useState<Toast[]>([]); |
| 32 | const timersRef = useRef<Map<string, number>>(new Map()); |
| 33 | |
| 34 | const dismissToast = useCallback((id: string) => { |
| 35 | timersRef.current.delete(id); |
| 36 | setToasts((current) => current.filter((item) => item.id !== id)); |
| 37 | }, []); |
| 38 | |
| 39 | useEffect(() => { |
| 40 | const timers = timersRef.current; |
| 41 | return () => { |
| 42 | for (const handle of timers.values()) { |
| 43 | window.clearTimeout(handle); |
| 44 | } |
| 45 | }; |
| 46 | }, []); |
| 47 | |
| 48 | const pushToast = useCallback( |
| 49 | (input: Omit<Toast, "id">) => { |
| 50 | const id = createToastId(); |
| 51 | setToasts((current) => [...current, { ...input, id }]); |
| 52 | if (input.tone !== "error") { |
| 53 | const handle = window.setTimeout(() => dismissToast(id), TOAST_TIMEOUT_MS); |
| 54 | timersRef.current.set(id, handle); |
| 55 | } |
| 56 | }, |
| 57 | [dismissToast], |
| 58 | ); |
| 59 | |
| 60 | const value: ToastContextValue = { pushToast }; |
| 61 | |
| 62 | return ( |
| 63 | <ToastContext.Provider value={value}> |
| 64 | {children} |
| 65 | <div |
| 66 | role="status" |
| 67 | aria-live="polite" |
| 68 | aria-atomic="true" |
| 69 | className="fixed bottom-4 right-4 z-[100] flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-3" |
| 70 | > |
| 71 | {toasts.map((toast) => { |
| 72 | const meta = TONE_STYLES[toast.tone]; |
| 73 | const Icon = meta.icon; |
| 74 | |
| 75 | return ( |
| 76 | <div |
| 77 | key={toast.id} |
| 78 | className={[ |
| 79 | "animate-slide-up rounded-2xl border p-4 shadow-lg shadow-black/30 backdrop-blur-sm", |
| 80 | meta.panel, |
| 81 | ].join(" ")} |
| 82 | > |
| 83 | <div className="flex items-start gap-3"> |
| 84 | <Icon aria-hidden="true" className="mt-0.5 h-5 w-5 shrink-0" /> |
| 85 | <div className="min-w-0 flex-1"> |
| 86 | <p className="text-sm font-semibold">{toast.title}</p> |
| 87 | {toast.message ? <p className="mt-1 text-sm text-current/80">{toast.message}</p> : null} |
| 88 | </div> |
| 89 | <button |
| 90 | type="button" |
| 91 | aria-label="Dismiss" |
| 92 | className="shrink-0 rounded-lg p-1 text-current/60 transition-colors hover:text-current" |
| 93 | onClick={() => dismissToast(toast.id)} |
| 94 | > |
| 95 | <X aria-hidden="true" className="h-4 w-4" /> |
| 96 | </button> |
| 97 | </div> |
| 98 | </div> |
| 99 | ); |
| 100 | })} |
| 101 | </div> |
| 102 | </ToastContext.Provider> |
| 103 | ); |
| 104 | }; |