feat: add Toast composite (ToastProvider + useToast)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 13:35:04 +01:00
parent 59923ed132
commit c247256d8a
3 changed files with 586 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
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, string> = {
info: '',
success: '✓',
warning: '⚠',
error: '✕',
}
// ── 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"
>
&times;
</button>
</div>
)
}