import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import "@shared/components/Toast.css"; export type ToastTone = "info" | "success" | "warning" | "danger"; export interface ToastOptions { tone?: ToastTone; title?: ReactNode; description?: ReactNode; /** Auto-dismiss timeout in ms. Defaults to 4000. Pass 0 to keep open. */ durationMs?: number; } interface ToastEntry extends ToastOptions { id: number; } interface ToastContextValue { toast: (options: ToastOptions) => number; dismiss: (id: number) => void; } const ToastContext = createContext(null); export function useToast(): ToastContextValue { const ctx = useContext(ToastContext); if (!ctx) throw new Error("useToast must be used inside "); return ctx; } export function ToastProvider({ children }: { children: ReactNode }) { const [entries, setEntries] = useState([]); const nextIdRef = useRef(1); const dismiss = useCallback((id: number) => { setEntries((prev) => prev.filter((e) => e.id !== id)); }, []); const toast = useCallback( (options: ToastOptions) => { const id = nextIdRef.current++; const entry: ToastEntry = { tone: "info", durationMs: 4000, ...options, id, }; setEntries((prev) => [...prev, entry]); if ((entry.durationMs ?? 0) > 0) { window.setTimeout(() => dismiss(id), entry.durationMs); } return id; }, [dismiss], ); const value = useMemo( () => ({ toast, dismiss }), [toast, dismiss], ); return ( {children} ); } function ToastViewport({ entries, onDismiss, }: { entries: ToastEntry[]; onDismiss: (id: number) => void; }) { // Render through a portal so toasts always sit above any in-flow stacking // context. SSR-safe by checking for document existence. if (typeof document === "undefined") return null; return createPortal(
{entries.map((entry) => ( ))}
, document.body, ); } function ToastItem({ entry, onDismiss, }: { entry: ToastEntry; onDismiss: (id: number) => void; }) { useEffect(() => { // Allow Escape to dismiss the most recently focused toast. function onKey(e: KeyboardEvent) { if (e.key === "Escape") onDismiss(entry.id); } document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [entry.id, onDismiss]); return (
{entry.title &&
{entry.title}
} {entry.description && (
{entry.description}
)}
); }