import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, useLayoutEffect } from 'react'; import { CheckCircle2, AlertCircle, Info, XCircle, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; export type ToastVariant = 'success' | 'info' | 'warning' | 'error'; const MAX_VISIBLE_TOASTS = 4; const DEFAULT_TOAST_DURATION_MS = 7000; const ERROR_TOAST_DURATION_MS = 10000; const TOAST_EXIT_DURATION_MS = 220; export interface ToastMessage { id: string; message: React.ReactNode; variant?: ToastVariant; duration?: number; isActionable?: boolean; onDismiss?: () => void; } interface ToastState extends ToastMessage { exiting?: boolean; } interface ToastContextType { addToast: (toast: Omit) => string; removeToast: (id: string) => void; } const ToastContext = createContext(undefined); export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [toasts, setToasts] = useState([]); const nextToastId = useRef(0); const addToast = useCallback((toast: Omit) => { nextToastId.current += 1; const id = `toast-${nextToastId.current}`; setToasts(prev => { const next = [...prev, { ...toast, id }]; return next.slice(-MAX_VISIBLE_TOASTS); }); return id; }, []); const removeToast = useCallback((id: string) => { setToasts(prev => { // Prevent multiple exit calls and re-renders for the same ID if (prev.find(t => t.id === id)?.exiting) return prev; return prev.map(t => t.id === id ? { ...t, exiting: true } : t); }); }, []); const removeToastCompletely = useCallback((id: string) => { setToasts(prev => prev.filter(t => t.id !== id)); }, []); return ( {children} ); }; export const useToast = () => { const context = useContext(ToastContext); if (!context) throw new Error('useToast must be used within ToastProvider'); return context; }; const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void; removeToastCompletely: (id: string) => void }> = ({ toast, removeToast, removeToastCompletely }) => { const { t } = useTranslation(); const [isMounted, setIsMounted] = useState(false); const [isHovered, setIsHovered] = useState(false); const timerStartedAt = useRef(null); const remainingDuration = useRef(null); const onDismissCalled = useRef(false); useLayoutEffect(() => { const frame = requestAnimationFrame(() => setIsMounted(true)); return () => cancelAnimationFrame(frame); }, []); useEffect(() => { if (remainingDuration.current === null) { remainingDuration.current = getToastDuration(toast); } if (toast.exiting || isHovered || remainingDuration.current === null) { return; } timerStartedAt.current = Date.now(); const timer = setTimeout(() => { removeToast(toast.id); }, remainingDuration.current); return () => { clearTimeout(timer); if (timerStartedAt.current !== null && remainingDuration.current !== null) { remainingDuration.current = Math.max(0, remainingDuration.current - (Date.now() - timerStartedAt.current)); timerStartedAt.current = null; } }; }, [toast, isHovered, removeToast]); useEffect(() => { const dismiss = () => { if (onDismissCalled.current) return; onDismissCalled.current = true; toast.onDismiss?.(); }; if (toast.exiting) dismiss(); return dismiss; }, [toast.exiting, toast.onDismiss]); useEffect(() => { if (toast.exiting) { const fallbackTimer = setTimeout(() => { removeToastCompletely(toast.id); }, TOAST_EXIT_DURATION_MS + 100); return () => clearTimeout(fallbackTimer); } }, [toast.exiting, toast.id, removeToastCompletely]); const variant = toast.variant || 'info'; const role = variant === 'info' ? 'status' : 'alert'; const ariaLive = variant === 'info' ? 'polite' : 'assertive'; const variantStyles = { success: { accent: 'bg-emerald-500', icon: 'text-emerald-500', }, info: { accent: 'bg-blue-500', icon: 'text-blue-500', }, warning: { accent: 'bg-amber-500', icon: 'text-amber-500', }, error: { accent: 'bg-red-500', icon: 'text-red-500', }, }; const icons = { success: , error: , warning: , info: , }; const style = variantStyles[variant]; const Icon = icons[variant]; const isVisible = isMounted && !toast.exiting; const transitionTiming = isVisible ? '220ms cubic-bezier(0.16, 1, 0.3, 1)' : `${TOAST_EXIT_DURATION_MS}ms cubic-bezier(0.4, 0, 1, 1)`; return (
{ if (toast.exiting && e.target === e.currentTarget && e.propertyName === 'grid-template-rows') { removeToastCompletely(toast.id); } }} >
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onFocus={() => setIsHovered(true)} onBlur={() => setIsHovered(false)} >
{Icon}
{toast.message}
); }; const getToastDuration = (toast: ToastState): number | null => { if (toast.duration === 0 || toast.isActionable) return null; if (typeof toast.duration === 'number') return Math.max(0, toast.duration); return toast.variant === 'error' ? ERROR_TOAST_DURATION_MS : DEFAULT_TOAST_DURATION_MS; }; const ToastContainer: React.FC<{ toasts: ToastState[]; removeToast: (id: string) => void; removeToastCompletely: (id: string) => void }> = ({ toasts, removeToast, removeToastCompletely }) => { return (
{toasts.map(toast => ( ))}
); };