Skip to content
File

Blob: src/client/toast/toast-provider.tsx

typescript105 lines
1import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
2import { AlertCircle, CheckCircle2, Info, X } from "lucide-react";
3import { ToastContext, type Toast, type ToastContextValue, type ToastTone } from "@/client/toast/toast-context";
4 
5const TOAST_TIMEOUT_MS = 4000;
6 
7const 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 
28const createToastId = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
29 
30export 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};