"use client" import * as React from "react" import { createPortal } from "react-dom" import { AlertCircleIcon, CheckCircle2Icon, InfoIcon, Loader2Icon, TriangleAlertIcon, XIcon } from "lucide-react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" export type ToastTone = "default" | "success" | "info" | "warning" | "danger" | "loading" export type ToastItem = { id: string title?: React.ReactNode description?: React.ReactNode tone?: ToastTone action?: React.ReactNode duration?: number dismissible?: boolean group?: React.ReactNode } export type CreateToastInput = Omit & { id?: string } export type ToastShortcutInput = React.ReactNode | CreateToastInput export type ToastPromiseMessages = { loading: ToastShortcutInput success: ToastShortcutInput | ((data: TData) => ToastShortcutInput) error: ToastShortcutInput | ((error: unknown) => ToastShortcutInput) } export type ToastContextValue = { toasts: ToastItem[] addToast: (toast: CreateToastInput) => string updateToast: (id: string, toast: Partial>) => void dismissToast: (id: string) => void clearToasts: () => void success: (toast: ToastShortcutInput) => string info: (toast: ToastShortcutInput) => string warning: (toast: ToastShortcutInput) => string error: (toast: ToastShortcutInput) => string loading: (toast: ToastShortcutInput) => string promise: (promise: Promise, messages: ToastPromiseMessages) => Promise } const ToastContext = React.createContext(null) const toneClassName: Record = { default: "border-border bg-popover text-popover-foreground", success: "border-emerald-500/20 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100", info: "border-blue-500/20 bg-blue-500/10 text-blue-950 dark:text-blue-100", warning: "border-amber-500/20 bg-amber-500/10 text-amber-950 dark:text-amber-100", danger: "border-destructive/20 bg-destructive/10 text-destructive", loading: "border-border bg-popover text-popover-foreground", } const toneIcon: Record = { default: , success: , info: , warning: , danger: , loading: , } function createToastId() { return `toast-${Date.now()}-${Math.random().toString(16).slice(2)}` } function isToastObject(toast: ToastShortcutInput): toast is CreateToastInput { return typeof toast === "object" && toast !== null && !React.isValidElement(toast) && !Array.isArray(toast) } function normalizeShortcutToast(toast: ToastShortcutInput, tone: ToastTone): CreateToastInput { if (isToastObject(toast)) { return { ...toast, tone: toast.tone ?? tone } } return { title: toast, tone } } function resolveToastMessage(message: ToastShortcutInput | ((data: TData) => ToastShortcutInput), data: TData) { return typeof message === "function" ? message(data) : message } export type ToastProviderProps = React.PropsWithChildren<{ defaultDuration?: number maxToasts?: number pauseOnHover?: boolean position?: "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center" }> const positionClassName: Record, string> = { "top-right": "right-4 top-4", "top-left": "left-4 top-4", "bottom-right": "bottom-4 right-4", "bottom-left": "bottom-4 left-4", "top-center": "left-1/2 top-4 -translate-x-1/2", "bottom-center": "bottom-4 left-1/2 -translate-x-1/2", } function ToastProvider({ children, defaultDuration = 4000, maxToasts = 5, pauseOnHover = true, position = "top-right", }: ToastProviderProps) { const [toasts, setToasts] = React.useState([]) const [portalTarget, setPortalTarget] = React.useState(null) React.useEffect(() => { setPortalTarget(document.body) }, []) const dismissToast = React.useCallback((id: string) => { setToasts((current) => current.filter((toast) => toast.id !== id)) }, []) const addToast = React.useCallback( (toast: CreateToastInput) => { const id = toast.id ?? createToastId() const nextToast: ToastItem = { tone: "default", duration: defaultDuration, dismissible: true, ...toast, id, } setToasts((current) => [nextToast, ...current.filter((item) => item.id !== id)].slice(0, maxToasts)) return id }, [defaultDuration, maxToasts] ) const updateToast = React.useCallback((id: string, toast: Partial>) => { setToasts((current) => current.map((item) => (item.id === id ? { ...item, ...toast } : item))) }, []) const clearToasts = React.useCallback(() => { setToasts([]) }, []) const success = React.useCallback((toast: ToastShortcutInput) => addToast(normalizeShortcutToast(toast, "success")), [addToast]) const info = React.useCallback((toast: ToastShortcutInput) => addToast(normalizeShortcutToast(toast, "info")), [addToast]) const warning = React.useCallback((toast: ToastShortcutInput) => addToast(normalizeShortcutToast(toast, "warning")), [addToast]) const error = React.useCallback((toast: ToastShortcutInput) => addToast(normalizeShortcutToast(toast, "danger")), [addToast]) const loading = React.useCallback((toast: ToastShortcutInput) => addToast({ duration: 0, ...normalizeShortcutToast(toast, "loading") }), [addToast]) const promise = React.useCallback( async (targetPromise: Promise, messages: ToastPromiseMessages) => { const id = addToast({ duration: 0, dismissible: false, ...normalizeShortcutToast(messages.loading, "loading") }) try { const data = await targetPromise updateToast(id, { duration: defaultDuration, dismissible: true, ...normalizeShortcutToast(resolveToastMessage(messages.success, data), "success"), }) return data } catch (promiseError) { updateToast(id, { duration: defaultDuration, dismissible: true, ...normalizeShortcutToast(resolveToastMessage(messages.error, promiseError), "danger"), }) throw promiseError } }, [addToast, defaultDuration, updateToast] ) const value = React.useMemo( () => ({ toasts, addToast, updateToast, dismissToast, clearToasts, success, info, warning, error, loading, promise, }), [addToast, clearToasts, dismissToast, error, info, loading, promise, success, toasts, updateToast, warning] ) const viewport = (
{toasts.map((toast) => ( ))}
) return ( {children} {portalTarget ? createPortal(viewport, portalTarget) : null} ) } function ToastCard({ toast, pauseOnHover, onDismiss, }: { toast: ToastItem pauseOnHover: boolean onDismiss: (id: string) => void }) { const tone = toast.tone ?? "default" const [hovered, setHovered] = React.useState(false) React.useEffect(() => { if (toast.duration === Infinity || toast.duration === 0) return if (pauseOnHover && hovered) return const timer = window.setTimeout(() => onDismiss(toast.id), toast.duration ?? 4000) return () => window.clearTimeout(timer) }, [hovered, onDismiss, pauseOnHover, toast.duration, toast.id]) return (
setHovered(true)} onMouseLeave={() => setHovered(false)} >
{toneIcon[tone]}
{toast.group ?
{toast.group}
: null} {toast.title &&
{toast.title}
} {toast.description &&
{toast.description}
} {toast.action &&
{toast.action}
}
{toast.dismissible !== false && ( )}
) } function useToast() { const context = React.useContext(ToastContext) if (!context) { throw new Error("useToast must be used inside ToastProvider") } return context } export { ToastProvider, useToast }