import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' // ─── Types ──────────────────────────────────────────────────────────────────── export type ToastType = 'success' | 'error' | 'warning' | 'info' export interface ToastItem { id: string type: ToastType title: string description?: string duration?: number } interface ToastContextValue { toast: (item: Omit) => void dismiss: (id: string) => void } // ─── Config ─────────────────────────────────────────────────────────────────── const toastConfig: Record< ToastType, { accent: string iconBg: string iconRing: string iconColor: string icon: string autoDismiss: boolean } > = { success: { accent: 'bg-green-500', iconBg: 'bg-green-50', iconRing: 'ring-green-100', iconColor: 'text-green-500', icon: 'check', autoDismiss: true, }, error: { accent: 'bg-red-500', iconBg: 'bg-red-50', iconRing: 'ring-red-100', iconColor: 'text-red-500', icon: 'error', autoDismiss: false, }, warning: { accent: 'bg-amber-500', iconBg: 'bg-amber-50', iconRing: 'ring-amber-100', iconColor: 'text-amber-500', icon: 'warning', autoDismiss: true, }, info: { accent: 'bg-blue-500', iconBg: 'bg-blue-50', iconRing: 'ring-blue-100', iconColor: 'text-blue-500', icon: 'info', autoDismiss: true, }, } const DEFAULT_TOAST_DURATION = 5000 export const getToastDismissDuration = (item: ToastItem): number | null => { const cfg = toastConfig[item.type] if (item.duration !== undefined) { return item.duration > 0 ? item.duration : null } return cfg.autoDismiss ? DEFAULT_TOAST_DURATION : null } // ─── Context ────────────────────────────────────────────────────────────────── const ToastContext = createContext(null) export function useToast(): ToastContextValue { const ctx = useContext(ToastContext) if (!ctx) throw new Error('useToast must be used inside ') return ctx } // ─── Individual Toast ───────────────────────────────────────────────────────── const ToastItem: React.FC<{ item: ToastItem; onDismiss: (id: string) => void }> = ({ item, onDismiss, }) => { const cfg = toastConfig[item.type] const timerRef = useRef>() const duration = getToastDismissDuration(item) useEffect(() => { if (duration !== null) { timerRef.current = setTimeout(() => onDismiss(item.id), duration) } return () => clearTimeout(timerRef.current) }, [item.id, duration, onDismiss]) return (
) } // ─── Provider ───────────────────────────────────────────────────────────────── const TOAST_STYLE_ID = 'cp-toast-styles' const TOAST_KEYFRAMES = ` @keyframes cpSlideInFromRight { from { transform: translateX(calc(100% + 16px)); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes cpToastProgress { from { transform: scaleX(1); } to { transform: scaleX(0); } } .cp-toast-enter { animation: cpSlideInFromRight 0.3s ease-out both; } .cp-toast-progress { animation-name: cpToastProgress; animation-timing-function: linear; animation-fill-mode: forwards; transform-origin: left; } ` export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [toasts, setToasts] = useState([]) useEffect(() => { if (typeof document === 'undefined' || document.getElementById(TOAST_STYLE_ID)) return const style = document.createElement('style') style.id = TOAST_STYLE_ID style.textContent = TOAST_KEYFRAMES document.head.appendChild(style) }, []) const dismiss = useCallback((id: string) => { setToasts((prev) => prev.filter((t) => t.id !== id)) }, []) const toast = useCallback((item: Omit) => { const id = `${Date.now()}-${Math.random()}` setToasts((prev) => { const next = [...prev, { ...item, id }] return next.slice(-3) }) }, []) return ( {children} {typeof document !== 'undefined' && createPortal(
{toasts.map((item) => ( ))}
, document.body )}
) }