/** * Minimal toast / notification system. * Use the `useToast` hook to fire toasts from any component. */ import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import { X, CheckCircle2, AlertTriangle, XCircle, Info } from "lucide-react"; // ── Types ───────────────────────────────────────────────────────────────── export type ToastType = "success" | "error" | "warning" | "info"; export interface Toast { id: string; type: ToastType; title: string; message?: string; duration?: number; // ms — 0 = sticky } interface ToastCtx { toasts: Toast[]; toast: (t: Omit) => string; dismiss: (id: string) => void; } // ── Context ──────────────────────────────────────────────────────────────── const ToastContext = createContext(null); export function useToast() { const ctx = useContext(ToastContext); if (!ctx) throw new Error("useToast must be used inside "); return ctx; } // ── Icon helper ──────────────────────────────────────────────────────────── function ToastIcon({ type }: { type: ToastType }) { const cls = "w-4 h-4 shrink-0 mt-0.5"; if (type === "success") return ; if (type === "error") return ; if (type === "warning") return ; return ; } const BORDER_COLOR: Record = { success: "border-green-500/40", error: "border-red-500/40", warning: "border-yellow-500/40", info: "border-blue-500/40", }; const BG_COLOR: Record = { success: "bg-green-500/10", error: "bg-red-500/10", warning: "bg-yellow-500/10", info: "bg-blue-500/10", }; // ── Single toast item ───────────────────────────────────────────────────── function ToastItem({ t, onDismiss }: { t: Toast; onDismiss: () => void }) { const [visible, setVisible] = useState(false); const timerRef = useRef | null>(null); useEffect(() => { // Slight delay so CSS enter transition fires const raf = requestAnimationFrame(() => setVisible(true)); const dur = t.duration ?? 4500; if (dur > 0) { timerRef.current = setTimeout(() => setVisible(false), dur); } return () => { cancelAnimationFrame(raf); if (timerRef.current) clearTimeout(timerRef.current); }; }, [t.duration]); // When exit animation ends, notify parent const handleTransitionEnd = () => { if (!visible) onDismiss(); }; return (
{t.title}
{t.message && (
{t.message}
)}
); } // ── Container ────────────────────────────────────────────────────────────── function ToastContainer({ toasts, dismiss }: { toasts: Toast[]; dismiss: (id: string) => void }) { if (!toasts.length) return null; return (
{toasts.map((t) => (
dismiss(t.id)} />
))}
); } // ── Provider ─────────────────────────────────────────────────────────────── export function ToastProvider({ children }: { children: React.ReactNode }) { const [toasts, setToasts] = useState([]); const toast = useCallback((t: Omit): string => { const id = `t-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; setToasts((p) => [...p, { ...t, id }]); return id; }, []); const dismiss = useCallback((id: string) => { setToasts((p) => p.filter((t) => t.id !== id)); }, []); return ( {children} ); }