turash/bugulma/frontend/components/ui/Toast.tsx

203 lines
5.2 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { clsx } from 'clsx';
import { X, CheckCircle2, AlertCircle, Info, AlertTriangle } from 'lucide-react';
import Button from './Button';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastProps {
id: string;
message: string;
description?: string;
type?: ToastType;
duration?: number;
onClose: (id: string) => void;
action?: {
label: string;
onClick: () => void;
};
}
const toastIcons = {
success: CheckCircle2,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
};
const toastStyles = {
success: 'bg-success/10 border-success/20 text-success-foreground',
error: 'bg-destructive/10 border-destructive/20 text-destructive-foreground',
warning: 'bg-warning/10 border-warning/20 text-warning-foreground',
info: 'bg-primary/10 border-primary/20 text-primary-foreground',
};
/**
* Individual toast notification
*/
export const Toast = ({
id,
message,
description,
type = 'info',
duration = 5000,
onClose,
action,
}: ToastProps) => {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(() => onClose(id), 300); // Wait for animation
}, duration);
return () => clearTimeout(timer);
}
}, [duration, id, onClose]);
const Icon = toastIcons[type];
if (!isVisible) return null;
return (
<div
className={clsx(
'relative flex items-start gap-3 p-4 rounded-lg border shadow-lg',
'min-w-[300px] max-w-[500px]',
'animate-in slide-in-from-top-5 fade-in-0 duration-300',
toastStyles[type]
)}
role="alert"
>
<Icon className="h-5 w-5 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{message}</p>
{description && (
<p className="text-sm mt-1 opacity-90">{description}</p>
)}
{action && (
<div className="mt-3">
<Button
variant="outline"
size="sm"
onClick={() => {
action.onClick();
onClose(id);
}}
>
{action.label}
</Button>
</div>
)}
</div>
<button
type="button"
onClick={() => {
setIsVisible(false);
setTimeout(() => onClose(id), 300);
}}
className="flex-shrink-0 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring transition-opacity"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
);
};
/**
* Toast container/provider
*/
export interface ToastContainerProps {
toasts: ToastProps[];
onClose: (id: string) => void;
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';
}
export const ToastContainer = ({
toasts,
onClose,
position = 'top-right',
}: ToastContainerProps) => {
const positionClasses = {
'top-right': 'top-4 right-4',
'top-left': 'top-4 left-4',
'bottom-right': 'bottom-4 right-4',
'bottom-left': 'bottom-4 left-4',
'top-center': 'top-4 left-1/2 -translate-x-1/2',
'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2',
};
if (toasts.length === 0) return null;
return (
<div
className={clsx(
'fixed z-50 flex flex-col gap-2',
positionClasses[position]
)}
role="region"
aria-live="polite"
aria-label="Notifications"
>
{toasts.map((toast) => (
<Toast key={toast.id} {...toast} onClose={onClose} />
))}
</div>
);
};
/**
* Hook for managing toasts
*/
export const useToast = () => {
const [toasts, setToasts] = useState<ToastProps[]>([]);
const showToast = (
message: string,
options?: {
description?: string;
type?: ToastType;
duration?: number;
action?: { label: string; onClick: () => void };
}
) => {
const id = `toast-${Date.now()}-${Math.random()}`;
const newToast: ToastProps = {
id,
message,
description: options?.description,
type: options?.type || 'info',
duration: options?.duration ?? 5000,
onClose: (id) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
},
action: options?.action,
};
setToasts((prev) => [...prev, newToast]);
return id;
};
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
return {
toasts,
showToast,
removeToast,
success: (message: string, options?: Omit<Parameters<typeof showToast>[1], 'type'>) =>
showToast(message, { ...options, type: 'success' }),
error: (message: string, options?: Omit<Parameters<typeof showToast>[1], 'type'>) =>
showToast(message, { ...options, type: 'error' }),
warning: (message: string, options?: Omit<Parameters<typeof showToast>[1], 'type'>) =>
showToast(message, { ...options, type: 'warning' }),
info: (message: string, options?: Omit<Parameters<typeof showToast>[1], 'type'>) =>
showToast(message, { ...options, type: 'info' }),
};
};