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,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%);
}
}

View File

@@ -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<typeof useToast>
function Consumer() {
api = useToast()
return null
}
render(
<ToastProvider>
<Consumer />
</ToastProvider>,
)
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(<BadComponent />)).toThrow('useToast must be used within a ToastProvider')
consoleSpy.mockRestore()
})
})

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