File
Blob: src/components/toast.tsx
| 1 | import { useSyncExternalStore } from "react"; |
| 2 | import { CircleAlert, CircleCheck, Info, X } from "lucide-react"; |
| 3 | import { cn } from "@/lib/cn"; |
| 4 | |
| 5 | type ToastType = "success" | "error" | "info"; |
| 6 | |
| 7 | interface ToastItem { |
| 8 | id: number; |
| 9 | type: ToastType; |
| 10 | message: string; |
| 11 | } |
| 12 | |
| 13 | const AUTO_DISMISS_MS = 4000; |
| 14 | |
| 15 | let items: ToastItem[] = []; |
| 16 | let nextId = 1; |
| 17 | const listeners = new Set<() => void>(); |
| 18 | |
| 19 | function emit() { |
| 20 | for (const listener of listeners) listener(); |
| 21 | } |
| 22 | |
| 23 | function dismiss(id: number) { |
| 24 | items = items.filter((t) => t.id !== id); |
| 25 | emit(); |
| 26 | } |
| 27 | |
| 28 | function 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 | |
| 35 | export 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 | |
| 41 | const 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 | |
| 47 | const ICONS: Record<ToastType, typeof Info> = { |
| 48 | success: CircleCheck, |
| 49 | error: CircleAlert, |
| 50 | info: Info, |
| 51 | }; |
| 52 | |
| 53 | export 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 | } |