diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index 34c0c17..40184e1 100644 --- a/src/contexts/ToastContext.tsx +++ b/src/contexts/ToastContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from 'react'; +import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, useLayoutEffect } from 'react'; import { CheckCircle2, AlertCircle, Info, XCircle, X } from 'lucide-react'; export type ToastVariant = 'success' | 'info' | 'warning' | 'error'; @@ -32,16 +32,21 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) = }, []); const removeToast = useCallback((id: string) => { - setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t)); - setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== id)); - }, 300); // Matches the exit animation duration + 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} - + ); }; @@ -52,17 +57,40 @@ export const useToast = () => { return context; }; -const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void }> = ({ toast, removeToast }) => { +const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void; removeToastCompletely: (id: string) => void }> = ({ toast, removeToast, removeToastCompletely }) => { + const [isMounted, setIsMounted] = useState(false); const [isHovered, setIsHovered] = useState(false); + const elementRef = useRef(null); + const [contentHeight, setContentHeight] = useState(undefined); + + useLayoutEffect(() => { + if (elementRef.current && !isMounted) { + // Measure natural height of the toast + setContentHeight(elementRef.current.offsetHeight); + + let frame2: number; + const frame1 = requestAnimationFrame(() => { + frame2 = requestAnimationFrame(() => setIsMounted(true)); + }); + return () => { + cancelAnimationFrame(frame1); + if (frame2) cancelAnimationFrame(frame2); + }; + } + }, [isMounted]); useEffect(() => { - let timeoutDuration = toast.duration ?? 5000; - if (timeoutDuration < 5000) timeoutDuration = 5000; + // 1. If exiting, don't trigger the auto-dismiss timer + if (toast.exiting) return; - if (toast.isActionable || (toast.variant === 'error' && !toast.duration)) { + // Explicitly treat a duration of 0 as a permanent toast + if (toast.duration === 0 || toast.isActionable || (toast.variant === 'error' && !toast.duration)) { return; } + let timeoutDuration = toast.duration ?? 5000; + if (timeoutDuration < 5000) timeoutDuration = 5000; + if (isHovered) { return; } @@ -74,6 +102,16 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void return () => clearTimeout(timer); }, [toast, isHovered, removeToast]); + // Fallback safety timer just in case browser drops onTransitionEnd + useEffect(() => { + if (toast.exiting) { + const fallbackTimer = setTimeout(() => { + removeToastCompletely(toast.id); + }, 500); + return () => clearTimeout(fallbackTimer); + } + }, [toast.exiting, toast.id, removeToastCompletely]); + const role = toast.variant === 'error' ? 'alert' : 'status'; const variantStyles = { @@ -94,43 +132,55 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void const style = variantStyles[variant]; const Icon = icons[variant]; + const isVisible = isMounted && !toast.exiting; + return (
{ + // 2. Rely on height transition end to safely unmount, instead of hardcoded fixed setTimeouts + if (toast.exiting && e.target === e.currentTarget && e.propertyName === 'height') { + removeToastCompletely(toast.id); + } }} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - onFocus={() => setIsHovered(true)} - onBlur={() => setIsHovered(false)} > -
{Icon}
-
{toast.message}
- +
{Icon}
+
{toast.message}
+ +
); }; -const ToastContainer: React.FC<{ toasts: ToastState[]; removeToast: (id: string) => void }> = ({ toasts, removeToast }) => { +const ToastContainer: React.FC<{ toasts: ToastState[]; removeToast: (id: string) => void; removeToastCompletely: (id: string) => void }> = ({ toasts, removeToast, removeToastCompletely }) => { return ( -
- + // 3. Removed 'gap-3' to allow the dynamic mb-3 from the wrapper to handle spacing, allowing it to smoothly collapse +
{toasts.map(toast => ( - + ))}
);