From c247256d8a9c242d803c33ca39caf3ea84c60ce6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:35:04 +0100 Subject: [PATCH] feat: add Toast composite (ToastProvider + useToast) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composites/Toast/Toast.module.css | 144 ++++++++++ .../composites/Toast/Toast.test.tsx | 252 ++++++++++++++++++ src/design-system/composites/Toast/Toast.tsx | 190 +++++++++++++ 3 files changed, 586 insertions(+) create mode 100644 src/design-system/composites/Toast/Toast.module.css create mode 100644 src/design-system/composites/Toast/Toast.test.tsx create mode 100644 src/design-system/composites/Toast/Toast.tsx diff --git a/src/design-system/composites/Toast/Toast.module.css b/src/design-system/composites/Toast/Toast.module.css new file mode 100644 index 0000000..df4735f --- /dev/null +++ b/src/design-system/composites/Toast/Toast.module.css @@ -0,0 +1,144 @@ +/* ── Container ────────────────────────────────────────────────────────────── */ + +.container { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 1100; + display: flex; + flex-direction: column; + gap: 8px; + /* newest at bottom — column order matches DOM order */ +} + +/* ── Toast item ───────────────────────────────────────────────────────────── */ + +.toast { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 300px; + max-width: 420px; + background: var(--bg-surface); + box-shadow: var(--shadow-lg); + border-radius: var(--radius-md); + padding: 12px 16px; + border-left: 4px solid transparent; + font-family: var(--font-body); + animation: slideIn 0.25s ease-out forwards; +} + +.dismissing { + animation: fadeOut 0.3s ease-in forwards; +} + +/* ── Variant accent colors ────────────────────────────────────────────────── */ + +.success { + border-left-color: var(--success); +} + +.success .icon { + color: var(--success); +} + +.warning { + border-left-color: var(--warning); +} + +.warning .icon { + color: var(--warning); +} + +.error { + border-left-color: var(--error); +} + +.error .icon { + color: var(--error); +} + +.info { + border-left-color: var(--running); +} + +.info .icon { + color: var(--running); +} + +/* ── Icon ─────────────────────────────────────────────────────────────────── */ + +.icon { + font-size: 15px; + line-height: 1.4; + flex-shrink: 0; +} + +/* ── Content ──────────────────────────────────────────────────────────────── */ + +.content { + flex: 1; + min-width: 0; +} + +.title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.4; +} + +.description { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + line-height: 1.4; +} + +/* ── Close button ─────────────────────────────────────────────────────────── */ + +.closeBtn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + line-height: 1; + flex-shrink: 0; + transition: all 0.15s; +} + +.closeBtn:hover { + color: var(--text-primary); + border-color: var(--text-faint); +} + +/* ── Animations ───────────────────────────────────────────────────────────── */ + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } +} diff --git a/src/design-system/composites/Toast/Toast.test.tsx b/src/design-system/composites/Toast/Toast.test.tsx new file mode 100644 index 0000000..430dca9 --- /dev/null +++ b/src/design-system/composites/Toast/Toast.test.tsx @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, act, fireEvent } from '@testing-library/react' +import { ToastProvider, useToast } from './Toast' + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** Renders a ToastProvider and exposes the useToast API via a ref-like object */ +function renderProvider() { + let api!: ReturnType + + function Consumer() { + api = useToast() + return null + } + + render( + + + , + ) + + return { getApi: () => api } +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('Toast', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it('shows a toast when toast() is called', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Hello', variant: 'info' }) }) + + expect(screen.getByTestId('toast')).toBeInTheDocument() + expect(screen.getByText('Hello')).toBeInTheDocument() + }) + + it('renders title and description', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Success!', variant: 'success', description: 'It worked.' }) }) + + expect(screen.getByText('Success!')).toBeInTheDocument() + expect(screen.getByText('It worked.')).toBeInTheDocument() + }) + + it('applies correct variant data attribute — error', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Error!', variant: 'error' }) }) + + expect(screen.getByTestId('toast')).toHaveAttribute('data-variant', 'error') + }) + + it('applies success variant', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Success!', variant: 'success' }) }) + + expect(screen.getByTestId('toast')).toHaveAttribute('data-variant', 'success') + }) + + it('applies warning variant', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Warning!', variant: 'warning' }) }) + + expect(screen.getByTestId('toast')).toHaveAttribute('data-variant', 'warning') + }) + + it('applies info variant by default when no variant given', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Default' }) }) + + expect(screen.getByTestId('toast')).toHaveAttribute('data-variant', 'info') + }) + + it('shows correct icon for info variant', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Info', variant: 'info' }) }) + + expect(screen.getByText('ℹ')).toBeInTheDocument() + }) + + it('shows correct icon for success variant', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'OK', variant: 'success' }) }) + + expect(screen.getByText('✓')).toBeInTheDocument() + }) + + it('shows correct icon for warning variant', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) }) + + expect(screen.getByText('⚠')).toBeInTheDocument() + }) + + it('shows correct icon for error variant', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Err', variant: 'error' }) }) + + expect(screen.getByText('✕')).toBeInTheDocument() + }) + + it('dismisses toast when close button is clicked', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Closeable' }) }) + expect(screen.getByTestId('toast')).toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Dismiss notification')) + + // After exit animation duration + act(() => { vi.advanceTimersByTime(300) }) + + expect(screen.queryByTestId('toast')).not.toBeInTheDocument() + }) + + it('auto-dismisses after default duration (5000ms)', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Auto' }) }) + expect(screen.getByTestId('toast')).toBeInTheDocument() + + // Advance past default duration + exit animation + act(() => { vi.advanceTimersByTime(5000 + 300) }) + + expect(screen.queryByTestId('toast')).not.toBeInTheDocument() + }) + + it('is still visible just before auto-dismiss fires', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Auto', duration: 1000 }) }) + + act(() => { vi.advanceTimersByTime(999) }) + + expect(screen.getByTestId('toast')).toBeInTheDocument() + }) + + it('auto-dismisses after custom duration', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Fast', duration: 1000 }) }) + + act(() => { vi.advanceTimersByTime(1000 + 300) }) + + expect(screen.queryByTestId('toast')).not.toBeInTheDocument() + }) + + it('renders multiple toasts', () => { + const { getApi } = renderProvider() + + act(() => { + getApi().toast({ title: 'First', variant: 'info' }) + getApi().toast({ title: 'Second', variant: 'error' }) + getApi().toast({ title: 'Third', variant: 'warning' }) + }) + + expect(screen.getAllByTestId('toast')).toHaveLength(3) + }) + + it('caps visible toasts at 5', () => { + const { getApi } = renderProvider() + + act(() => { + for (let i = 0; i < 7; i++) { + getApi().toast({ title: `Toast ${i}` }) + } + }) + + expect(screen.getAllByTestId('toast')).toHaveLength(5) + }) + + it('keeps newest 5 when capped', () => { + const { getApi } = renderProvider() + + act(() => { + for (let i = 0; i < 7; i++) { + getApi().toast({ title: `Toast ${i}` }) + } + }) + + // Toast 0 and 1 should be gone (oldest), Toast 2-6 remain + expect(screen.queryByText('Toast 0')).not.toBeInTheDocument() + expect(screen.queryByText('Toast 1')).not.toBeInTheDocument() + expect(screen.getByText('Toast 6')).toBeInTheDocument() + }) + + it('returns a string id from toast()', () => { + const { getApi } = renderProvider() + + let id!: string + act(() => { id = getApi().toast({ title: 'Test' }) }) + + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + }) + + it('dismiss() by id removes only the specified toast', () => { + const { getApi } = renderProvider() + + let id1!: string + act(() => { + id1 = getApi().toast({ title: 'First' }) + getApi().toast({ title: 'Second' }) + }) + + expect(screen.getAllByTestId('toast')).toHaveLength(2) + + act(() => { getApi().dismiss(id1) }) + act(() => { vi.advanceTimersByTime(300) }) + + expect(screen.getAllByTestId('toast')).toHaveLength(1) + expect(screen.queryByText('First')).not.toBeInTheDocument() + expect(screen.getByText('Second')).toBeInTheDocument() + }) + + it('toast container has aria-live attribute', () => { + const { getApi } = renderProvider() + + act(() => { getApi().toast({ title: 'Accessible' }) }) + + expect(screen.getByLabelText('Notifications')).toBeInTheDocument() + expect(screen.getByLabelText('Notifications')).toHaveAttribute('aria-live', 'polite') + }) + + it('throws if useToast is used outside ToastProvider', () => { + function BadComponent() { + useToast() + return null + } + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(() => render()).toThrow('useToast must be used within a ToastProvider') + consoleSpy.mockRestore() + }) +}) diff --git a/src/design-system/composites/Toast/Toast.tsx b/src/design-system/composites/Toast/Toast.tsx new file mode 100644 index 0000000..c5220a3 --- /dev/null +++ b/src/design-system/composites/Toast/Toast.tsx @@ -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> { + 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 = { + info: 'ℹ', + success: '✓', + warning: '⚠', + error: '✕', +} + +// ── Context ──────────────────────────────────────────────────────────────── + +const ToastContext = createContext(null) + +// ── ToastProvider ────────────────────────────────────────────────────────── + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const timersRef = useRef>>(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 ( + + {children} + + + ) +} + +// ── 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( +
+ {toasts.map((t) => ( + + ))} +
, + document.body, + ) +} + +// ── ToastItem ────────────────────────────────────────────────────────────── + +interface ToastItemComponentProps { + toast: ToastItem + onDismiss: (id: string) => void +} + +function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) { + return ( +
+ +
+
{toast.title}
+ {toast.description && ( +
{toast.description}
+ )} +
+ +
+ ) +}