diff --git a/src/design-system/primitives/Alert/Alert.module.css b/src/design-system/primitives/Alert/Alert.module.css new file mode 100644 index 0000000..0fcaff1 --- /dev/null +++ b/src/design-system/primitives/Alert/Alert.module.css @@ -0,0 +1,93 @@ +.alert { + display: flex; + align-items: flex-start; + gap: 10px; + width: 100%; + border-radius: var(--radius-md); + padding: 12px 16px; + border-left: 4px solid transparent; + box-sizing: border-box; + font-family: var(--font-body); + position: relative; +} + +/* Variants */ +.info { + background: var(--running-bg); + border-left-color: var(--running); +} + +.success { + background: var(--success-bg); + border-left-color: var(--success); +} + +.warning { + background: var(--warning-bg); + border-left-color: var(--warning); +} + +.error { + background: var(--error-bg); + border-left-color: var(--error); +} + +/* Icon */ +.icon { + flex-shrink: 0; + font-size: 14px; + line-height: 1.4; +} + +.info .icon { color: var(--running); } +.success .icon { color: var(--success); } +.warning .icon { color: var(--warning); } +.error .icon { color: var(--error); } + +/* Content */ +.content { + flex: 1; + min-width: 0; +} + +.title { + font-size: 13px; + font-weight: 600; + line-height: 1.4; + margin-bottom: 2px; +} + +.info .title { color: var(--running); } +.success .title { color: var(--success); } +.warning .title { color: var(--warning); } +.error .title { color: var(--error); } + +.body { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Dismiss button */ +.dismissBtn { + flex-shrink: 0; + width: 24px; + height: 24px; + 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: 14px; + padding: 0; + line-height: 1; + margin-left: auto; +} + +.dismissBtn:hover { + background: var(--border); + color: var(--text-primary, var(--text-secondary)); +} diff --git a/src/design-system/primitives/Alert/Alert.test.tsx b/src/design-system/primitives/Alert/Alert.test.tsx new file mode 100644 index 0000000..50d72a1 --- /dev/null +++ b/src/design-system/primitives/Alert/Alert.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Alert } from './Alert' + +describe('Alert', () => { + it('renders children', () => { + render(Something went wrong) + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('renders title when provided', () => { + render(Body text) + expect(screen.getByText('Heads up')).toBeInTheDocument() + expect(screen.getByText('Body text')).toBeInTheDocument() + }) + + it('renders without title', () => { + render(Just a message) + expect(screen.getByText('Just a message')).toBeInTheDocument() + }) + + it('uses role="alert" for error variant', () => { + render(Error message) + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + it('uses role="alert" for warning variant', () => { + render(Warning message) + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + it('uses role="status" for info variant', () => { + render(Info message) + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('uses role="status" for success variant', () => { + render(Success message) + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('defaults to info variant (role="status")', () => { + render(Default alert) + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('shows default icon for each variant', () => { + const { rerender } = render(msg) + expect(screen.getByText('ℹ')).toBeInTheDocument() + + rerender(msg) + expect(screen.getByText('✓')).toBeInTheDocument() + + rerender(msg) + expect(screen.getByText('⚠')).toBeInTheDocument() + + rerender(msg) + expect(screen.getByText('✕')).toBeInTheDocument() + }) + + it('renders a custom icon when provided', () => { + render(★}>Custom icon alert) + expect(screen.getByText('★')).toBeInTheDocument() + // Default icon should not appear + expect(screen.queryByText('ℹ')).not.toBeInTheDocument() + }) + + it('does not show dismiss button when dismissible is false', () => { + render(Non-dismissible) + expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument() + }) + + it('shows dismiss button when dismissible is true', () => { + render(Dismissible alert) + expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument() + }) + + it('calls onDismiss when dismiss button is clicked', async () => { + const onDismiss = vi.fn() + render( + + Dismissible alert + , + ) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /dismiss/i })) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('applies a custom className', () => { + const { container } = render(Alert) + expect(container.firstChild).toHaveClass('my-custom-class') + }) + + it('applies the correct variant class', () => { + const { container } = render(Error) + expect(container.firstChild).toHaveClass('error') + }) +}) diff --git a/src/design-system/primitives/Alert/Alert.tsx b/src/design-system/primitives/Alert/Alert.tsx new file mode 100644 index 0000000..e6b637f --- /dev/null +++ b/src/design-system/primitives/Alert/Alert.tsx @@ -0,0 +1,69 @@ +import { ReactNode } from 'react' +import styles from './Alert.module.css' + +type AlertVariant = 'info' | 'success' | 'warning' | 'error' + +interface AlertProps { + variant?: AlertVariant + title?: string + children?: ReactNode + dismissible?: boolean + onDismiss?: () => void + icon?: ReactNode + className?: string +} + +const DEFAULT_ICONS: Record = { + info: 'ℹ', + success: '✓', + warning: '⚠', + error: '✕', +} + +const ARIA_ROLES: Record = { + error: 'alert', + warning: 'alert', + info: 'status', + success: 'status', +} + +export function Alert({ + variant = 'info', + title, + children, + dismissible = false, + onDismiss, + icon, + className, +}: AlertProps) { + const resolvedIcon = icon !== undefined ? icon : DEFAULT_ICONS[variant] + const role = ARIA_ROLES[variant] + + const classes = [styles.alert, styles[variant], className ?? ''] + .filter(Boolean) + .join(' ') + + return ( +
+ + +
+ {title &&
{title}
} + {children &&
{children}
} +
+ + {dismissible && ( + + )} +
+ ) +}