"use client"; import { CircleAlertIcon, CircleCheckIcon, InfoIcon, LoaderCircleIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, type PanInfo, useReducedMotion } from "motion/react"; import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type ToastTone = "default" | "success" | "error" | "info" | "loading"; export interface ToastData { id: string; title: ReactNode; description?: ReactNode; tone?: ToastTone; action?: { label: string; onClick: () => void }; duration?: number; } type ToastInput = Omit & { id?: string }; type PromiseMessages = { loading: ReactNode; success: ReactNode | ((value: T) => ReactNode); error: ReactNode | ((error: unknown) => ReactNode); description?: ReactNode; }; let counter = 0; export function useToastDeck({ limit = 4 }: { limit?: number } = {}) { const [toasts, setToasts] = useState([]); const dismiss = useCallback((id: string) => setToasts((list) => list.filter((t) => t.id !== id)), []); const show = useCallback((input: ToastInput) => { const id = input.id ?? `t${++counter}`; setToasts((list) => { const exists = list.some((t) => t.id === id); return exists ? list.map((t) => (t.id === id ? { ...t, ...input, id } : t)) : [...list, { ...input, id }].slice(-limit); }); return id; }, [limit]); const promise = useCallback( async (task: Promise, messages: PromiseMessages) => { const id = show({ title: messages.loading, description: messages.description, tone: "loading", duration: Infinity }); try { const value = await task; const title = typeof messages.success === "function" ? messages.success(value) : messages.success; show({ id, title, tone: "success", description: undefined, duration: 4000 }); return value; } catch (error) { const title = typeof messages.error === "function" ? messages.error(error) : messages.error; show({ id, title, tone: "error", description: undefined, duration: 5000 }); throw error; } }, [show], ); return useMemo(() => ({ toasts, show, dismiss, promise }), [toasts, show, dismiss, promise]); } export type ToastDeckStore = ReturnType; const icons: Record = { default: null, success: , error: , info: , loading: , }; const PEEK = 10; const GAP = 8; const VISIBLE = 3; export interface ToastDeckProps { deck: ToastDeckStore; className?: string; } export function ToastDeck({ deck, className }: ToastDeckProps) { const { toasts, dismiss } = deck; const [expanded, setExpanded] = useState(false); const [heights, setHeights] = useState>({}); const reduce = useReducedMotion(); const newestFirst = [...toasts].reverse(); const frontHeight = heights[newestFirst[0]?.id] ?? 64; const offsets: number[] = []; let running = 0; for (const t of newestFirst) { offsets.push(running); running += (heights[t.id] ?? 64) + GAP; } const stackHeight = expanded ? Math.max(running - GAP, 0) : frontHeight + PEEK * Math.min(Math.max(newestFirst.length - 1, 0), VISIBLE - 1); useEffect(() => { if (toasts.length === 0) setExpanded(false); }, [toasts.length]); const setHeight = useCallback((id: string, h: number) => { setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h })); }, []); return (
{ if (!e.currentTarget.contains(e.relatedTarget as Node)) setExpanded(false); }} onFocus={() => setExpanded(true)} onMouseEnter={() => setExpanded(true)} onMouseLeave={() => setExpanded(false)} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 36 }} > {newestFirst.map((toast, index) => ( dismiss(toast.id)} onHeight={setHeight} paused={expanded} toast={toast} /> ))}
); } interface ToastItemProps { toast: ToastData; index: number; offset: number; expanded: boolean; frontHeight: number; paused: boolean; onDismiss: () => void; onHeight: (id: string, h: number) => void; } function ToastItem({ toast, index, offset, expanded, frontHeight, paused, onDismiss, onHeight }: ToastItemProps) { const reduce = useReducedMotion(); const contentRef = useRef(null); const [natural, setNatural] = useState(64); const [swipe, setSwipe] = useState(0); const tone = toast.tone ?? "default"; const duration = toast.duration ?? 4000; useLayoutEffect(() => { const el = contentRef.current; if (!el) return; const measure = () => { setNatural(el.offsetHeight); onHeight(toast.id, el.offsetHeight); }; measure(); const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); }, [toast.id, onHeight]); const remaining = useRef(duration); useEffect(() => { remaining.current = duration; }, [duration, toast.title]); useEffect(() => { if (paused || !Number.isFinite(remaining.current)) return; const started = Date.now(); const id = window.setTimeout(onDismiss, remaining.current); return () => { clearTimeout(id); remaining.current -= Date.now() - started; }; }, [paused, onDismiss, toast.title, duration]); const hidden = !expanded && index >= VISIBLE; const y = expanded ? -offset : -index * PEEK; const scale = expanded ? 1 : 1 - index * 0.05; const height = expanded || index === 0 ? natural : frontHeight; const onDragEnd = (_: unknown, info: PanInfo) => { if (Math.abs(info.offset.x) > 90 || Math.abs(info.velocity.x) > 600) { setSwipe(info.offset.x > 0 ? 1 : -1); onDismiss(); } }; const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 380, damping: 32 }; return ( ); }