feat: enhance toast component with enter/exit animations and progress bar
This commit is contained in:
+28
-4
@@ -306,9 +306,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Toast animations */
|
/* Toast animations */
|
||||||
@keyframes slide-in {
|
@keyframes toast-enter {
|
||||||
from {
|
from {
|
||||||
transform: translateX(100%);
|
transform: translateX(calc(100% + 1.25rem));
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
@@ -317,8 +317,32 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-slide-in {
|
@keyframes toast-exit {
|
||||||
animation: slide-in 0.3s ease-out;
|
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 */
|
/* Mobile Responsive Utilities */
|
||||||
|
|||||||
+126
-48
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
import { createPortal } from "react-dom";
|
||||||
|
import { X, Check, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type ToastType = "success" | "error" | "info" | "warning";
|
export type ToastType = "success" | "error" | "info" | "warning";
|
||||||
@@ -28,93 +29,170 @@ interface ToastProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
success: CheckCircle,
|
success: Check,
|
||||||
error: AlertCircle,
|
error: AlertCircle,
|
||||||
info: Info,
|
info: Info,
|
||||||
warning: AlertTriangle,
|
warning: AlertTriangle,
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = {
|
const iconContainerStyles = {
|
||||||
success: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200",
|
success: "bg-emerald-500 text-white",
|
||||||
error: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200",
|
error: "bg-red-500 text-white",
|
||||||
info: "bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200",
|
info: "bg-blue-500 text-white",
|
||||||
warning: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200",
|
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) {
|
export function ToastItem({ toast, onClose }: ToastProps) {
|
||||||
const Icon = icons[toast.type];
|
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<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
setExiting(true);
|
||||||
|
setTimeout(() => onClose(toast.id), 280);
|
||||||
|
}, [onClose, toast.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toast.duration && toast.duration > 0) {
|
if (!toast.duration || toast.duration <= 0) return;
|
||||||
const timer = setTimeout(() => {
|
|
||||||
onClose(toast.id);
|
if (paused) {
|
||||||
}, toast.duration);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
return () => clearTimeout(timer);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-background animate-slide-in",
|
"toast-item group relative flex items-start gap-3 w-[380px] rounded-lg",
|
||||||
styles[toast.type],
|
"bg-background/95 dark:bg-neutral-900/95 backdrop-blur-sm",
|
||||||
toast.onClick && !toast.action && "cursor-pointer hover:opacity-90 transition-opacity"
|
"border border-border/60 dark:border-neutral-700/60",
|
||||||
|
"shadow-[0_8px_30px_rgb(0,0,0,0.08)] dark:shadow-[0_8px_30px_rgb(0,0,0,0.3)]",
|
||||||
|
"overflow-hidden",
|
||||||
|
exiting ? "toast-exit" : "toast-enter",
|
||||||
|
toast.onClick && !toast.action && "cursor-pointer"
|
||||||
)}
|
)}
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (toast.onClick && !toast.action) {
|
if (toast.onClick && !toast.action) {
|
||||||
toast.onClick();
|
toast.onClick();
|
||||||
onClose(toast.id);
|
dismiss();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{toast.icon !== undefined ? toast.icon : <Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />}
|
{/* Left accent bar */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-lg", progressBarStyles[toast.type])} />
|
||||||
<h4 className="font-medium">{toast.title}</h4>
|
|
||||||
{toast.message && (
|
<div className="flex items-start gap-3 p-3.5 pl-4.5 flex-1 min-w-0">
|
||||||
<p className="text-sm mt-1 opacity-90">{toast.message}</p>
|
{/* Icon */}
|
||||||
)}
|
{toast.icon !== undefined ? (
|
||||||
</div>
|
toast.icon
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
) : (
|
||||||
{toast.action && (
|
<div className={cn("flex items-center justify-center w-7 h-7 rounded-full flex-shrink-0", iconContainerStyles[toast.type])}>
|
||||||
<button
|
<Icon className="w-3.5 h-3.5" strokeWidth={2.5} />
|
||||||
onClick={(e) => {
|
</div>
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
toast.action!.onClick();
|
|
||||||
onClose(toast.id);
|
|
||||||
} catch {
|
|
||||||
// Don't close toast on error so user can retry
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-sm font-medium underline underline-offset-2 hover:opacity-80 transition-opacity whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{toast.action.label}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
|
<p className="text-[13px] font-semibold text-foreground leading-tight">{toast.title}</p>
|
||||||
|
{toast.message && (
|
||||||
|
<p className="text-[12px] mt-1 text-muted-foreground leading-snug">{toast.message}</p>
|
||||||
|
)}
|
||||||
|
{toast.action && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
toast.action!.onClick();
|
||||||
|
dismiss();
|
||||||
|
} catch {
|
||||||
|
// Don't close toast on error so user can retry
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
|
||||||
|
"bg-foreground/5 hover:bg-foreground/10 dark:bg-white/10 dark:hover:bg-white/15",
|
||||||
|
"text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{toast.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClose(toast.id);
|
dismiss();
|
||||||
}}
|
}}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className={cn(
|
||||||
|
"flex-shrink-0 p-1 rounded-md transition-all",
|
||||||
|
"text-muted-foreground/60 hover:text-foreground hover:bg-foreground/5",
|
||||||
|
"opacity-0 group-hover:opacity-100"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{toast.duration && toast.duration > 0 && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground/5">
|
||||||
|
<div
|
||||||
|
className={cn("h-full rounded-full opacity-60", progressBarStyles[toast.type])}
|
||||||
|
style={{
|
||||||
|
animation: `toast-progress ${toast.duration}ms linear forwards`,
|
||||||
|
animationPlayState: paused ? "paused" : "running",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
|
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(
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm"
|
className="fixed bottom-5 right-5 z-[99999] flex flex-col-reverse gap-2.5"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
style={{ pointerEvents: "none" }}
|
||||||
>
|
>
|
||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<ToastItem key={toast.id} toast={toast} onClose={onClose} />
|
<div key={toast.id} style={{ pointerEvents: "auto" }}>
|
||||||
|
<ToastItem toast={toast} onClose={onClose} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user