Skip to content
File

Blob: src/components/toast.tsx

typescript93 lines
1import { useSyncExternalStore } from "react";
2import { CircleAlert, CircleCheck, Info, X } from "lucide-react";
3import { cn } from "@/lib/cn";
4 
5type ToastType = "success" | "error" | "info";
6 
7interface ToastItem {
8 id: number;
9 type: ToastType;
10 message: string;
11}
12 
13const AUTO_DISMISS_MS = 4000;
14 
15let items: ToastItem[] = [];
16let nextId = 1;
17const listeners = new Set<() => void>();
18 
19function emit() {
20 for (const listener of listeners) listener();
21}
22 
23function dismiss(id: number) {
24 items = items.filter((t) => t.id !== id);
25 emit();
26}
27 
28function push(type: ToastType, message: string) {
29 const id = nextId++;
30 items = [...items, { id, type, message }];
31 emit();
32 setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
33}
34 
35export const toast = {
36 success: (message: string) => push("success", message),
37 error: (message: string) => push("error", message),
38 info: (message: string) => push("info", message),
39};
40 
41const STYLES: Record<ToastType, string> = {
42 success: "border-emerald-500/30 text-emerald-400",
43 error: "border-red-500/30 text-red-400",
44 info: "border-accent-500/30 text-accent-400",
45};
46 
47const ICONS: Record<ToastType, typeof Info> = {
48 success: CircleCheck,
49 error: CircleAlert,
50 info: Info,
51};
52 
53export function ToastContainer() {
54 const toasts = useSyncExternalStore(
55 (cb) => {
56 listeners.add(cb);
57 return () => listeners.delete(cb);
58 },
59 () => items,
60 () => items,
61 );
62 
63 return (
64 <div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
65 {toasts.map((t) => {
66 const Icon = ICONS[t.type];
67 return (
68 <div
69 key={t.id}
70 role="status"
71 aria-live={t.type === "error" ? "assertive" : "polite"}
72 className={cn(
73 "animate-slide-up pointer-events-auto flex items-center gap-2.5 rounded-xl border bg-zinc-800 px-3.5 py-2.5 text-sm text-zinc-100 shadow-2xl shadow-black/40",
74 STYLES[t.type],
75 )}
76 >
77 <Icon className="size-4 shrink-0" aria-hidden="true" />
78 <span className="text-zinc-100">{t.message}</span>
79 <button
80 type="button"
81 aria-label="Dismiss notification"
82 onClick={() => dismiss(t.id)}
83 className="-mr-1 ml-1 rounded-md p-0.5 text-zinc-500 hover:text-zinc-300"
84 >
85 <X className="size-3.5" aria-hidden="true" />
86 </button>
87 </div>
88 );
89 })}
90 </div>
91 );
92}