feat: add Toast composite (ToastProvider + useToast)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
144
src/design-system/composites/Toast/Toast.module.css
Normal file
144
src/design-system/composites/Toast/Toast.module.css
Normal 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%);
|
||||
}
|
||||
}
|
||||
252
src/design-system/composites/Toast/Toast.test.tsx
Normal file
252
src/design-system/composites/Toast/Toast.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
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