feat: add Toast composite (ToastProvider + useToast)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
190
src/design-system/composites/Toast/Toast.tsx
Normal file
190
src/design-system/composites/Toast/Toast.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user