/ src / components / Toast / Toast.tsx
Toast.tsx
 1  import * as React from 'react'
 2  import * as RadixToast from '@radix-ui/react-toast'
 3  import { X, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
 4  import { cn } from '../../lib/utils'
 5  
 6  export type ToastVariant = 'success' | 'error' | 'warning' | 'info'
 7  
 8  export interface ToastProps {
 9    open: boolean
10    onOpenChange: (open: boolean) => void
11    variant?: ToastVariant
12    title: string
13    description?: string
14    duration?: number
15  }
16  
17  const variantConfig: Record<ToastVariant, { icon: React.ReactNode; color: string; bg: string }> = {
18    success: {
19      icon: <CheckCircle size={16} aria-hidden="true" />,
20      color: 'oklch(65.4% 0.17 162)',
21      bg: 'oklch(65.4% 0.17 162 / 0.1)',
22    },
23    error: {
24      icon: <XCircle size={16} aria-hidden="true" />,
25      color: 'oklch(60% 0.22 25)',
26      bg: 'oklch(60% 0.22 25 / 0.1)',
27    },
28    warning: {
29      icon: <AlertTriangle size={16} aria-hidden="true" />,
30      color: 'oklch(72% 0.20 50)',
31      bg: 'oklch(72% 0.20 50 / 0.1)',
32    },
33    info: {
34      icon: <Info size={16} aria-hidden="true" />,
35      color: 'oklch(65% 0.18 264)',
36      bg: 'oklch(52.4% 0.22 264 / 0.1)',
37    },
38  }
39  
40  export function Toast({
41    open,
42    onOpenChange,
43    variant = 'info',
44    title,
45    description,
46    duration = 4000,
47  }: ToastProps) {
48    const cfg = variantConfig[variant]
49    return (
50      <RadixToast.Provider>
51        <RadixToast.Root
52          open={open}
53          onOpenChange={onOpenChange}
54          duration={duration}
55          className={cn(
56            'pointer-events-auto flex items-start gap-3 rounded-[var(--radius-md,6px)]',
57            'border border-[oklch(38%_0_0)] px-4 py-3',
58            'shadow-lg backdrop-blur-sm',
59            'data-[state=open]:animate-in data-[state=closed]:animate-out',
60            'data-[state=closed]:fade-out-80 data-[state=open]:fade-in-0',
61            'data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full',
62            'min-w-[300px] max-w-[420px]'
63          )}
64          style={{
65            backgroundColor: `color-mix(in oklch, ${cfg.bg}, oklch(20% 0 0) 65%)`,
66            borderLeft: `3px solid ${cfg.color}`,
67          }}
68        >
69          <span className="mt-0.5 shrink-0" style={{ color: cfg.color }}>
70            {cfg.icon}
71          </span>
72          <div className="flex-1">
73            <RadixToast.Title className="text-sm font-semibold text-[oklch(95%_0_0)]">
74              {title}
75            </RadixToast.Title>
76            {description && (
77              <RadixToast.Description className="mt-0.5 text-xs text-[oklch(65%_0_0)]">
78                {description}
79              </RadixToast.Description>
80            )}
81          </div>
82          <RadixToast.Close
83            className="shrink-0 rounded p-0.5 text-[oklch(55%_0_0)] opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(52.4%_0.22_264)]"
84            aria-label="Close notification"
85          >
86            <X size={14} aria-hidden="true" />
87          </RadixToast.Close>
88        </RadixToast.Root>
89        <RadixToast.Viewport className="fixed bottom-0 right-0 z-[100] flex max-h-screen flex-col-reverse gap-2 p-4" />
90      </RadixToast.Provider>
91    )
92  }