All checks were successful
Build & Publish / publish (push) Successful in 1m2s
Replace unicode characters, emoji, and inline SVGs with lucide-react components across the entire design system and page layer. Update tests to assert on SVG elements instead of text content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
192 lines
5.9 KiB
TypeScript
192 lines
5.9 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
|
import styles from './Toast.module.css'
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
export type ToastVariant = 'success' | 'warning' | 'error' | 'info'
|
|
|
|
export interface ToastOptions {
|
|
title: string
|
|
description?: string
|
|
variant?: ToastVariant
|
|
duration?: number
|
|
}
|
|
|
|
interface ToastItem extends Required<Pick<ToastOptions, 'title' | 'variant' | 'duration'>> {
|
|
id: string
|
|
description?: string
|
|
/** when true, plays the exit animation before removal */
|
|
dismissing: boolean
|
|
}
|
|
|
|
interface ToastContextValue {
|
|
toast: (options: ToastOptions) => string
|
|
dismiss: (id: string) => void
|
|
}
|
|
|
|
// ── Constants ──────────────────────────────────────────────────────────────
|
|
|
|
const MAX_TOASTS = 5
|
|
const DEFAULT_DURATION = 5000
|
|
const EXIT_ANIMATION_MS = 300
|
|
|
|
const ICONS: Record<ToastVariant, ReactNode> = {
|
|
info: <Info size={16} />,
|
|
success: <CheckCircle size={16} />,
|
|
warning: <AlertTriangle size={16} />,
|
|
error: <XCircle size={16} />,
|
|
}
|
|
|
|
// ── Context ────────────────────────────────────────────────────────────────
|
|
|
|
const ToastContext = createContext<ToastContextValue | null>(null)
|
|
|
|
// ── ToastProvider ──────────────────────────────────────────────────────────
|
|
|
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
|
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
|
|
|
const dismiss = useCallback((id: string) => {
|
|
// Clear auto-dismiss timer if running
|
|
const timer = timersRef.current.get(id)
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer)
|
|
timersRef.current.delete(id)
|
|
}
|
|
|
|
// Mark as dismissing (triggers exit animation)
|
|
setToasts((prev) =>
|
|
prev.map((t) => (t.id === id ? { ...t, dismissing: true } : t)),
|
|
)
|
|
|
|
// Remove after animation completes
|
|
setTimeout(() => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
}, EXIT_ANIMATION_MS)
|
|
}, [])
|
|
|
|
const toast = useCallback(
|
|
(options: ToastOptions): string => {
|
|
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
|
const duration = options.duration ?? DEFAULT_DURATION
|
|
const variant = options.variant ?? 'info'
|
|
|
|
const newToast: ToastItem = {
|
|
id,
|
|
title: options.title,
|
|
description: options.description,
|
|
variant,
|
|
duration,
|
|
dismissing: false,
|
|
}
|
|
|
|
setToasts((prev) => {
|
|
const next = [...prev, newToast]
|
|
// Keep only the last MAX_TOASTS entries (newest at the end)
|
|
return next.slice(-MAX_TOASTS)
|
|
})
|
|
|
|
// Schedule auto-dismiss
|
|
const timer = setTimeout(() => {
|
|
dismiss(id)
|
|
}, duration)
|
|
timersRef.current.set(id, timer)
|
|
|
|
return id
|
|
},
|
|
[dismiss],
|
|
)
|
|
|
|
// Clean up all timers on unmount
|
|
useEffect(() => {
|
|
const timers = timersRef.current
|
|
return () => {
|
|
timers.forEach(clearTimeout)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<ToastContext.Provider value={{ toast, dismiss }}>
|
|
{children}
|
|
<ToastContainer toasts={toasts} onDismiss={dismiss} />
|
|
</ToastContext.Provider>
|
|
)
|
|
}
|
|
|
|
// ── useToast ───────────────────────────────────────────────────────────────
|
|
|
|
export function useToast(): ToastContextValue {
|
|
const ctx = useContext(ToastContext)
|
|
if (!ctx) {
|
|
throw new Error('useToast must be used within a ToastProvider')
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
// ── ToastContainer (portal) ────────────────────────────────────────────────
|
|
|
|
interface ToastContainerProps {
|
|
toasts: ToastItem[]
|
|
onDismiss: (id: string) => void
|
|
}
|
|
|
|
function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
|
|
if (toasts.length === 0) return null
|
|
|
|
return createPortal(
|
|
<div className={styles.container} aria-live="polite" aria-label="Notifications">
|
|
{toasts.map((t) => (
|
|
<ToastItemComponent key={t.id} toast={t} onDismiss={onDismiss} />
|
|
))}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|
|
|
|
// ── ToastItem ──────────────────────────────────────────────────────────────
|
|
|
|
interface ToastItemComponentProps {
|
|
toast: ToastItem
|
|
onDismiss: (id: string) => void
|
|
}
|
|
|
|
function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
|
|
return (
|
|
<div
|
|
className={`${styles.toast} ${styles[toast.variant]} ${toast.dismissing ? styles.dismissing : ''}`}
|
|
role="alert"
|
|
data-testid="toast"
|
|
data-variant={toast.variant}
|
|
>
|
|
<span className={styles.icon} aria-hidden="true">
|
|
{ICONS[toast.variant]}
|
|
</span>
|
|
<div className={styles.content}>
|
|
<div className={styles.title}>{toast.title}</div>
|
|
{toast.description && (
|
|
<div className={styles.description}>{toast.description}</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
className={styles.closeBtn}
|
|
onClick={() => onDismiss(toast.id)}
|
|
aria-label="Dismiss notification"
|
|
type="button"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|