2026-02-06 23:01:30 -05:00

187 lines
5.8 KiB
TypeScript

'use client';
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/* ---------- Types ---------- */
export type ToastVariant = 'success' | 'error' | 'info' | 'loading';
export interface ToastItem {
id: string;
variant: ToastVariant;
message: string;
duration?: number; // ms — 0 = manual dismiss
}
export interface ToastAPI {
toast: (variant: ToastVariant, message: string, duration?: number) => string;
dismiss: (id: string) => void;
dismissAll: () => void;
}
/* ---------- Context ---------- */
const ToastContext = createContext<ToastAPI | null>(null);
export function useToast(): ToastAPI {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within <ToastProvider>');
return ctx;
}
/* ---------- Icons ---------- */
const CheckIcon: React.FC = () => (
<svg className="h-5 w-5 text-emerald-400 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
);
const ErrorIcon: React.FC = () => (
<svg className="h-5 w-5 text-red-400 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
);
const InfoIcon: React.FC = () => (
<svg className="h-5 w-5 text-blue-400 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
);
const LoadingIcon: React.FC = () => (
<svg className="h-5 w-5 text-gray-400 animate-spin shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
const icons: Record<ToastVariant, React.FC> = {
success: CheckIcon,
error: ErrorIcon,
info: InfoIcon,
loading: LoadingIcon,
};
const borderColors: Record<ToastVariant, string> = {
success: 'border-l-emerald-500',
error: 'border-l-red-500',
info: 'border-l-blue-500',
loading: 'border-l-gray-500',
};
const defaultDurations: Record<ToastVariant, number> = {
success: 5000,
error: 8000,
info: 5000,
loading: 0, // manual dismiss
};
/* ---------- Single Toast ---------- */
interface ToastCardProps {
item: ToastItem;
onDismiss: (id: string) => void;
className?: string;
}
const ToastCard: React.FC<ToastCardProps> = ({ item, onDismiss, className }) => {
const [exiting, setExiting] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const Icon = icons[item.variant];
const duration = item.duration ?? defaultDurations[item.variant];
useEffect(() => {
if (duration > 0) {
timerRef.current = setTimeout(() => {
setExiting(true);
setTimeout(() => onDismiss(item.id), 200);
}, duration);
}
return () => clearTimeout(timerRef.current);
}, [duration, item.id, onDismiss]);
return (
<div
role="alert"
className={twMerge(
clsx(
'flex items-start gap-3 px-4 py-3 rounded-lg border-l-4',
'bg-gray-900 border border-gray-700 shadow-lg shadow-black/30',
'transition-all duration-200 ease-out',
borderColors[item.variant],
exiting
? 'opacity-0 translate-x-4'
: 'opacity-100 translate-x-0 animate-[slideInRight_0.25s_ease-out]',
),
className,
)}
>
<Icon />
<p className="text-sm text-gray-200 flex-1 pt-0.5">{item.message}</p>
<button
onClick={() => {
setExiting(true);
setTimeout(() => onDismiss(item.id), 200);
}}
className="text-gray-500 hover:text-gray-300 transition-colors shrink-0 pt-0.5"
aria-label="Dismiss"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
);
};
/* ---------- Provider ---------- */
export interface ToastProviderProps {
children: React.ReactNode;
className?: string;
}
let globalId = 0;
export const ToastProvider: React.FC<ToastProviderProps> = ({ children, className }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const dismissAll = useCallback(() => setToasts([]), []);
const toast = useCallback(
(variant: ToastVariant, message: string, duration?: number): string => {
const id = `toast-${++globalId}`;
setToasts((prev) => [...prev, { id, variant, message, duration }]);
return id;
},
[],
);
return (
<ToastContext.Provider value={{ toast, dismiss, dismissAll }}>
{children}
{/* Toast container — bottom-right */}
<div
className={twMerge(
'fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-96 max-w-[calc(100vw-2rem)]',
className,
)}
aria-live="polite"
>
{toasts.map((t) => (
<ToastCard key={t.id} item={t} onDismiss={dismiss} />
))}
</div>
</ToastContext.Provider>
);
};
ToastProvider.displayName = 'ToastProvider';