Skip to content
File

Blob: src/client/components/toast.tsx

typescript66 lines
1import { useEffect, useState, useSyncExternalStore } from "react";
2import { CheckCircle2, CircleX, Info, X } from "lucide-react";
3 
4import { dismissToast, snapshot, subscribe, type ToastItem, type ToastVariant } from "@/client/lib/toast";
5 
6function ToastCard({ item }: { item: ToastItem }) {
7 const [visible, setVisible] = useState(true);
8 
9 useEffect(() => {
10 if (item.variant === "error") return;
11 const timeout = window.setTimeout(() => {
12 setVisible(false);
13 window.setTimeout(() => dismissToast(item.id), 200);
14 }, 4000);
15 return () => window.clearTimeout(timeout);
16 }, [item.id, item.variant]);
17 
18 const styles: Record<ToastVariant, { ring: string; iconClass: string; Icon: typeof CheckCircle2 }> = {
19 success: {
20 ring: "border-emerald-500/20 bg-emerald-500/10 text-emerald-100",
21 iconClass: "text-emerald-400",
22 Icon: CheckCircle2,
23 },
24 error: { ring: "border-red-500/20 bg-red-500/10 text-red-100", iconClass: "text-red-400", Icon: CircleX },
25 info: {
26 ring: "border-accent-500/20 bg-accent-500/10 text-accent-50",
27 iconClass: "text-accent-400",
28 Icon: Info,
29 },
30 };
31 const { ring, iconClass, Icon } = styles[item.variant];
32 
33 return (
34 <div
35 role={item.variant === "error" ? "alert" : "status"}
36 aria-live={item.variant === "error" ? "assertive" : "polite"}
37 className={`pointer-events-auto flex items-start gap-3 rounded-xl border ${ring} px-4 py-3 shadow-sm transition-opacity ${visible ? "opacity-100" : "opacity-0"}`}
38 >
39 <Icon className={`mt-0.5 h-4 w-4 shrink-0 ${iconClass}`} strokeWidth={2} aria-hidden="true" />
40 <p className="flex-1 text-sm leading-snug">{item.message}</p>
41 <button
42 type="button"
43 aria-label="Dismiss notification"
44 onClick={() => dismissToast(item.id)}
45 className="-mr-1 -mt-1 rounded-md p-1 text-current/60 transition-colors hover:bg-zinc-800/40 hover:text-current"
46 >
47 <X className="h-3.5 w-3.5" strokeWidth={2.25} aria-hidden="true" />
48 </button>
49 </div>
50 );
51}
52 
53export function ToastContainer() {
54 const current = useSyncExternalStore(subscribe, snapshot, snapshot);
55 if (current.length === 0) return null;
56 return (
57 <div className="pointer-events-none fixed inset-x-0 bottom-4 z-[100] flex flex-col items-end gap-2 px-4 sm:right-4 sm:left-auto sm:max-w-sm">
58 {current.map((item) => (
59 <div key={item.id} className="pointer-events-auto w-full animate-slide-up">
60 <ToastCard item={item} />
61 </div>
62 ))}
63 </div>
64 );
65}