1
9import * as React from "react";
10import { clsx } from "clsx";
11import { ToastStore, type Toast, type ToastKind } from "../toast-store.js";
12
13
14export const toastStore = new ToastStore();
15
16const KIND_COLOR: Record<ToastKind, string> = {
17 info: "var(--info)",
18 success: "var(--success)",
19 warning: "var(--warning)",
20 error: "var(--destructive)",
21};
22
23export function useToasts() {
24 const push = React.useCallback(
25 (kind: ToastKind, message: string, ttl?: number) => toastStore.push(kind, message, ttl),
26 [],
27 );
28 const dismiss = React.useCallback((id: string) => toastStore.dismiss(id), []);
29 return { push, dismiss } as const;
30}
31
32export interface ToastProviderProps {
33
34 store?: ToastStore;
35
36 max?: number;
37 children?: React.ReactNode;
38}
39
40export function ToastProvider({ store = toastStore, max = 5, children }: ToastProviderProps) {
41 const [items, setItems] = React.useState<readonly Toast[]>(() => store.items());
42 React.useEffect(() => store.subscribe(setItems), [store]);
43 const visible = items.slice(-max);
44
45 return (
46 <>
47 {children}
48 <div
49 aria-live="polite"
50 aria-label="Notifications"
51 className="fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2"
52 >
53 {visible.map((t) => (
54 <div
55 key={t.id}
56 role="status"
57 className={clsx(
58 "flex items-start justify-between gap-3 rounded-md border border-border",
59 "bg-popover px-3 py-2.5 text-sm text-popover-foreground shadow-md",
60 )}
61 style={{ borderLeft: `3px solid ${KIND_COLOR[t.kind]}` }}
62 >
63 <span>{t.message}</span>
64 <button
65 type="button"
66 aria-label="Dismiss notification"
67 onClick={() => store.dismiss(t.id)}
68 className="cursor-pointer text-muted-foreground hover:text-foreground"
69 >
70 ✕
71 </button>
72 </div>
73 ))}
74 </div>
75 </>
76 );
77}
78