diff --git a/app/globals.css b/app/globals.css index a9531d9d..d1ede0ed 100644 --- a/app/globals.css +++ b/app/globals.css @@ -306,9 +306,9 @@ body { } /* Toast animations */ -@keyframes slide-in { +@keyframes toast-enter { from { - transform: translateX(100%); + transform: translateX(calc(100% + 1.25rem)); opacity: 0; } to { @@ -317,8 +317,32 @@ body { } } -.animate-slide-in { - animation: slide-in 0.3s ease-out; +@keyframes toast-exit { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(calc(100% + 1.25rem)); + opacity: 0; + } +} + +@keyframes toast-progress { + from { + width: 100%; + } + to { + width: 0%; + } +} + +.toast-enter { + animation: toast-enter 0.32s cubic-bezier(0.21, 1.02, 0.73, 1) forwards; +} + +.toast-exit { + animation: toast-exit 0.28s cubic-bezier(0.06, 0.71, 0.55, 1) forwards; } /* Mobile Responsive Utilities */ diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index da0c046e..85ed88c6 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect } from "react"; -import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; +import { X, Check, AlertCircle, Info, AlertTriangle } from "lucide-react"; import { cn } from "@/lib/utils"; export type ToastType = "success" | "error" | "info" | "warning"; @@ -28,93 +29,170 @@ interface ToastProps { } const icons = { - success: CheckCircle, + success: Check, error: AlertCircle, info: Info, warning: AlertTriangle, }; -const styles = { - success: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200", - error: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200", - info: "bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200", - warning: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200", +const iconContainerStyles = { + success: "bg-emerald-500 text-white", + error: "bg-red-500 text-white", + info: "bg-blue-500 text-white", + warning: "bg-amber-500 text-white", +}; + +const progressBarStyles = { + success: "bg-emerald-500", + error: "bg-red-500", + info: "bg-blue-500", + warning: "bg-amber-500", }; export function ToastItem({ toast, onClose }: ToastProps) { const Icon = icons[toast.type]; + const [exiting, setExiting] = useState(false); + const [paused, setPaused] = useState(false); + const remainingRef = useRef(toast.duration ?? 5000); + const startRef = useRef(Date.now()); + const timerRef = useRef | null>(null); + + const dismiss = useCallback(() => { + setExiting(true); + setTimeout(() => onClose(toast.id), 280); + }, [onClose, toast.id]); useEffect(() => { - if (toast.duration && toast.duration > 0) { - const timer = setTimeout(() => { - onClose(toast.id); - }, toast.duration); - return () => clearTimeout(timer); + if (!toast.duration || toast.duration <= 0) return; + + if (paused) { + if (timerRef.current) clearTimeout(timerRef.current); + remainingRef.current = remainingRef.current - (Date.now() - startRef.current); + return; } - }, [toast, onClose]); + + startRef.current = Date.now(); + timerRef.current = setTimeout(dismiss, remainingRef.current); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [toast.duration, paused, dismiss]); return (
setPaused(true)} + onMouseLeave={() => setPaused(false)} onClick={() => { if (toast.onClick && !toast.action) { toast.onClick(); - onClose(toast.id); + dismiss(); } }} > - {toast.icon !== undefined ? toast.icon : } -
-

{toast.title}

- {toast.message && ( -

{toast.message}

- )} -
-
- {toast.action && ( - + {/* Left accent bar */} +
+ +
+ {/* Icon */} + {toast.icon !== undefined ? ( + toast.icon + ) : ( +
+ +
)} + + {/* Content */} +
+

{toast.title}

+ {toast.message && ( +

{toast.message}

+ )} + {toast.action && ( + + )} +
+ + {/* Close button */}
+ + {/* Progress bar */} + {toast.duration && toast.duration > 0 && ( +
+
+
+ )}
); } export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) { - return ( + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) return null; + + return createPortal(
{toasts.map((toast) => ( - +
+ +
))} -
+
, + document.body ); }