feat: add Alert primitive
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
93
src/design-system/primitives/Alert/Alert.module.css
Normal file
93
src/design-system/primitives/Alert/Alert.module.css
Normal file
@@ -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));
|
||||||
|
}
|
||||||
100
src/design-system/primitives/Alert/Alert.test.tsx
Normal file
100
src/design-system/primitives/Alert/Alert.test.tsx
Normal file
@@ -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(<Alert>Something went wrong</Alert>)
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders title when provided', () => {
|
||||||
|
render(<Alert title="Heads up">Body text</Alert>)
|
||||||
|
expect(screen.getByText('Heads up')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Body text')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders without title', () => {
|
||||||
|
render(<Alert>Just a message</Alert>)
|
||||||
|
expect(screen.getByText('Just a message')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses role="alert" for error variant', () => {
|
||||||
|
render(<Alert variant="error">Error message</Alert>)
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses role="alert" for warning variant', () => {
|
||||||
|
render(<Alert variant="warning">Warning message</Alert>)
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses role="status" for info variant', () => {
|
||||||
|
render(<Alert variant="info">Info message</Alert>)
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses role="status" for success variant', () => {
|
||||||
|
render(<Alert variant="success">Success message</Alert>)
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to info variant (role="status")', () => {
|
||||||
|
render(<Alert>Default alert</Alert>)
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows default icon for each variant', () => {
|
||||||
|
const { rerender } = render(<Alert variant="info">msg</Alert>)
|
||||||
|
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<Alert variant="success">msg</Alert>)
|
||||||
|
expect(screen.getByText('✓')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<Alert variant="warning">msg</Alert>)
|
||||||
|
expect(screen.getByText('⚠')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<Alert variant="error">msg</Alert>)
|
||||||
|
expect(screen.getByText('✕')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a custom icon when provided', () => {
|
||||||
|
render(<Alert icon={<span>★</span>}>Custom icon alert</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(<Alert>Non-dismissible</Alert>)
|
||||||
|
expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows dismiss button when dismissible is true', () => {
|
||||||
|
render(<Alert dismissible>Dismissible alert</Alert>)
|
||||||
|
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onDismiss when dismiss button is clicked', async () => {
|
||||||
|
const onDismiss = vi.fn()
|
||||||
|
render(
|
||||||
|
<Alert dismissible onDismiss={onDismiss}>
|
||||||
|
Dismissible alert
|
||||||
|
</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 className="my-custom-class">Alert</Alert>)
|
||||||
|
expect(container.firstChild).toHaveClass('my-custom-class')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the correct variant class', () => {
|
||||||
|
const { container } = render(<Alert variant="error">Error</Alert>)
|
||||||
|
expect(container.firstChild).toHaveClass('error')
|
||||||
|
})
|
||||||
|
})
|
||||||
69
src/design-system/primitives/Alert/Alert.tsx
Normal file
69
src/design-system/primitives/Alert/Alert.tsx
Normal file
@@ -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<AlertVariant, string> = {
|
||||||
|
info: 'ℹ',
|
||||||
|
success: '✓',
|
||||||
|
warning: '⚠',
|
||||||
|
error: '✕',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
||||||
|
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 (
|
||||||
|
<div className={classes} role={role}>
|
||||||
|
<span className={styles.icon} aria-hidden="true">
|
||||||
|
{resolvedIcon}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
{title && <div className={styles.title}>{title}</div>}
|
||||||
|
{children && <div className={styles.body}>{children}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dismissible && (
|
||||||
|
<button
|
||||||
|
className={styles.dismissBtn}
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss alert"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user