Files
design-system/src/design-system/composites/Toast/Toast.tsx
hsiegeln 433d582da6
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
feat: migrate all icons to Lucide React
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>
2026-03-27 23:25:43 +01:00

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>
)
}