Files
clustri/src/components/ui/toast.tsx
T
2026-07-29 13:47:45 +00:00

121 lines
3.2 KiB
TypeScript

import { createContext, useCallback, useContext, useState } from 'react'
import { cn } from '@/lib/utils'
import { X, CheckCircle, AlertTriangle, Info, AlertCircle } from 'lucide-react'
type ToastVariant = 'success' | 'error' | 'warning' | 'info'
interface Toast {
id: string
message: string
variant: ToastVariant
}
interface ToastContextValue {
toasts: Toast[]
addToast: (message: string, variant?: ToastVariant) => void
removeToast: (id: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const addToast = useCallback(
(message: string, variant: ToastVariant = 'info') => {
const id = crypto.randomUUID()
setToasts((prev) => [...prev, { id, message, variant }])
setTimeout(() => removeToast(id), 5000)
},
[removeToast]
)
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
)
}
function ToastContainer({
toasts,
onRemove,
}: {
toasts: Toast[]
onRemove: (id: string) => void
}) {
if (toasts.length === 0) return null
return (
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
))}
</div>
)
}
const variantStyles: Record<ToastVariant, { icon: React.ComponentType<{ className?: string }>; container: string }> = {
success: {
icon: CheckCircle,
container: 'bg-green-50 border-green-200 text-green-800 dark:bg-green-950 dark:border-green-800 dark:text-green-200',
},
error: {
icon: AlertCircle,
container: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200',
},
warning: {
icon: AlertTriangle,
container: 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-200',
},
info: {
icon: Info,
container: 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200',
},
}
function ToastItem({
toast,
onRemove,
}: {
toast: Toast
onRemove: (id: string) => void
}) {
const config = variantStyles[toast.variant]
const Icon = config.icon
return (
<div
className={cn(
'flex items-start gap-3 rounded-lg border p-4 shadow-lg',
config.container
)}
style={{
animation: 'toast-slide-in 0.3s ease-out',
}}
>
<Icon className="h-5 w-5 mt-0.5 shrink-0" />
<p className="text-sm flex-1">{toast.message}</p>
<button
onClick={() => onRemove(toast.id)}
className="shrink-0 rounded-md p-0.5 opacity-70 hover:opacity-100 transition-opacity"
>
<X className="h-4 w-4" />
</button>
</div>
)
}