` track/thumb, amber active state.
- [ ] **Step 4: Verify build**
```bash
npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add src/design-system/primitives/Select/ src/design-system/primitives/Checkbox/ src/design-system/primitives/Toggle/
git commit -m "feat: Select, Checkbox, Toggle form primitives"
```
---
### Task 9: Badge, Avatar, Tag
**Files:**
- Create: `src/design-system/primitives/Badge/{Badge.tsx,Badge.module.css,Badge.test.tsx}`
- Create: `src/design-system/primitives/Avatar/{Avatar.tsx,Avatar.module.css,Avatar.test.tsx}`
- Create: `src/design-system/primitives/Tag/{Tag.tsx,Tag.module.css}`
These components use `hashColor`. Tests should verify color derivation.
- [ ] **Step 1: Write Badge test**
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Badge } from './Badge'
describe('Badge', () => {
it('renders label text', () => {
render()
expect(screen.getByText('VIEWER')).toBeInTheDocument()
})
it('applies inline hash color when color is auto', () => {
const { container } = render()
const badge = container.firstChild as HTMLElement
expect(badge.style.backgroundColor).toBeTruthy()
})
it('applies semantic color class when specified', () => {
const { container } = render()
expect(container.firstChild).toHaveClass('error')
})
it('applies dashed variant class', () => {
const { container } = render()
expect(container.firstChild).toHaveClass('dashed')
})
})
```
- [ ] **Step 2: Implement Badge**
```tsx
// Badge.tsx
import styles from './Badge.module.css'
import { hashColor } from '../../utils/hashColor'
import { useTheme } from '../../providers/ThemeProvider'
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'
type BadgeVariant = 'filled' | 'outlined' | 'dashed'
interface BadgeProps {
label: string
variant?: BadgeVariant
color?: BadgeColor
onRemove?: () => void
className?: string
}
export function Badge({
label,
variant = 'filled',
color = 'auto',
onRemove,
className,
}: BadgeProps) {
const { theme } = useTheme()
const isAuto = color === 'auto'
const hashColors = isAuto ? hashColor(label, theme) : null
const inlineStyle = isAuto
? {
backgroundColor: variant === 'filled' ? hashColors!.bg : 'transparent',
color: hashColors!.text,
borderColor: hashColors!.border,
}
: undefined
const classes = [
styles.badge,
styles[variant],
!isAuto ? styles[color] : '',
className ?? '',
].filter(Boolean).join(' ')
return (
{label}
{onRemove && (
)}
)
}
```
```css
/* Badge.module.css */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 10px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
white-space: nowrap;
border: 1px solid transparent;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.filled { /* default — inline styles from hashColor or semantic class */ }
.outlined {
background: transparent !important;
}
.dashed {
background: transparent !important;
border-style: dashed;
}
.primary { background: var(--amber-bg); color: var(--amber-deep); border-color: var(--amber-light); }
.success { background: var(--success-bg); color: var(--success); border-color: var(--success-border); }
.warning { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); }
.error { background: var(--error-bg); color: var(--error); border-color: var(--error-border); }
.running { background: var(--running-bg); color: var(--running); border-color: var(--running-border); }
.remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 12px;
line-height: 1;
opacity: 0.5;
padding: 0;
}
.remove:hover { opacity: 1; }
```
- [ ] **Step 3: Implement Avatar**
Avatar extracts initials from name, uses `hashColor` for background. Sizes: `sm` (24px), `md` (28px), `lg` (40px).
```tsx
// Avatar.tsx
import styles from './Avatar.module.css'
import { hashColor } from '../../utils/hashColor'
import { useTheme } from '../../providers/ThemeProvider'
interface AvatarProps {
name: string
size?: 'sm' | 'md' | 'lg'
className?: string
}
function getInitials(name: string): string {
const parts = name.split(/[\s-]+/).filter(Boolean)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return name.slice(0, 2).toUpperCase()
}
export function Avatar({ name, size = 'md', className }: AvatarProps) {
const { theme } = useTheme()
const colors = hashColor(name, theme)
return (
{getInitials(name)}
)
}
```
```css
/* Avatar.module.css */
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 600;
border: 1px solid;
flex-shrink: 0;
}
.sm { width: 24px; height: 24px; font-size: 9px; }
.md { width: 28px; height: 28px; font-size: 11px; }
.lg { width: 40px; height: 40px; font-size: 14px; }
```
- [ ] **Step 4: Implement Tag**
Tag differs from Badge: it's always removable (primary use is active filter tags and group membership). Uses `hashColor` by default for color, or explicit semantic color. Has a prominent `x` dismiss button.
```tsx
// Tag.tsx
import styles from './Tag.module.css'
import { hashColor } from '../../utils/hashColor'
import { useTheme } from '../../providers/ThemeProvider'
type TagColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'
interface TagProps {
label: string
onRemove?: () => void
color?: TagColor
className?: string
}
export function Tag({ label, onRemove, color = 'auto', className }: TagProps) {
const { theme } = useTheme()
const isAuto = color === 'auto'
const hashColors = isAuto ? hashColor(label, theme) : null
const inlineStyle = isAuto
? { backgroundColor: hashColors!.bg, color: hashColors!.text, borderColor: hashColors!.border }
: undefined
const classes = [styles.tag, !isAuto ? styles[color] : '', className ?? ''].filter(Boolean).join(' ')
return (
{label}
{onRemove && (
)}
)
}
```
```css
/* Tag.module.css */
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border: 1px solid;
border-radius: 20px;
font-size: 11px;
font-family: var(--font-mono);
}
.primary { background: var(--amber-bg); color: var(--amber-deep); border-color: var(--amber-light); }
.success { background: var(--success-bg); color: var(--success); border-color: var(--success-border); }
.warning { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); }
.error { background: var(--error-bg); color: var(--error); border-color: var(--error-border); }
.running { background: var(--running-bg); color: var(--running); border-color: var(--running-border); }
.remove {
cursor: pointer;
opacity: 0.5;
font-size: 13px;
line-height: 1;
background: none;
border: none;
color: inherit;
padding: 0;
}
.remove:hover { opacity: 1; }
```
- [ ] **Step 5: Run tests**
```bash
npx vitest run src/design-system/primitives/Badge/ src/design-system/primitives/Avatar/
```
- [ ] **Step 6: Commit**
```bash
git add src/design-system/primitives/Badge/ src/design-system/primitives/Avatar/ src/design-system/primitives/Tag/
git commit -m "feat: Badge, Avatar, Tag primitives with hashColor integration"
```
---
### Task 10: Sparkline, Card, StatCard, FilterPill
**Files:**
- Create: `src/design-system/primitives/Sparkline/{Sparkline.tsx,Sparkline.test.tsx}`
- Create: `src/design-system/primitives/Card/{Card.tsx,Card.module.css}`
- Create: `src/design-system/primitives/StatCard/{StatCard.tsx,StatCard.module.css}`
- Create: `src/design-system/primitives/FilterPill/{FilterPill.tsx,FilterPill.module.css}`
- [ ] **Step 1: Implement and test Sparkline** (moved here from Task 12 because StatCard depends on it)
```tsx
// Sparkline.tsx
interface SparklineProps {
data: number[]
color?: string
width?: number
height?: number
strokeWidth?: number
className?: string
}
export function Sparkline({
data,
color = 'var(--amber)',
width = 60,
height = 20,
strokeWidth = 1.5,
className,
}: SparklineProps) {
if (data.length < 2) return null
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
const padding = 1
const points = data
.map((val, i) => {
const x = (i / (data.length - 1)) * (width - padding * 2) + padding
const y = height - padding - ((val - min) / range) * (height - padding * 2)
return `${x},${y}`
})
.join(' ')
return (
)
}
```
Test:
```tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Sparkline } from './Sparkline'
describe('Sparkline', () => {
it('renders an SVG with a polyline', () => {
const { container } = render()
expect(container.querySelector('svg')).toBeInTheDocument()
expect(container.querySelector('polyline')).toBeInTheDocument()
})
it('returns null for less than 2 data points', () => {
const { container } = render()
expect(container.firstChild).toBeNull()
})
})
```
- [ ] **Step 2: Implement Card** — surface container with optional top accent stripe. Reference `ui-mocks/mock-v2-light.html` lines 527-553 for exact styles.
- [ ] **Step 3: Implement StatCard** — extends Card with: accent stripe, `.stat-label`, large `.stat-value` (mono), trend arrow, detail line. Accepts optional `sparkline` data (renders Sparkline inline). Reference mock lines 558-612.
- [ ] **Step 3: Implement FilterPill** — selectable pill with dot + count + active states. Reference mock lines 656-697.
- [ ] **Step 4: Verify build**
```bash
npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add src/design-system/primitives/Sparkline/ src/design-system/primitives/Card/ src/design-system/primitives/StatCard/ src/design-system/primitives/FilterPill/
git commit -m "feat: Sparkline, Card, StatCard, FilterPill primitives"
```
---
### Task 11: InfoCallout, EmptyState, CodeBlock
**Files:**
- Create: `src/design-system/primitives/InfoCallout/{InfoCallout.tsx,InfoCallout.module.css}`
- Create: `src/design-system/primitives/EmptyState/{EmptyState.tsx,EmptyState.module.css}`
- Create: `src/design-system/primitives/CodeBlock/{CodeBlock.tsx,CodeBlock.module.css,CodeBlock.test.tsx}`
- [ ] **Step 1: Implement InfoCallout** — block with 3px left border (amber default, variant colors), light background.
- [ ] **Step 2: Implement EmptyState** — centered icon + title + description + optional action button.
- [ ] **Step 3: Write CodeBlock test**
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { CodeBlock } from './CodeBlock'
describe('CodeBlock', () => {
it('renders content in a pre element', () => {
render()
expect(screen.getByText(/"key"/)).toBeInTheDocument()
})
it('pretty-prints JSON when language is json', () => {
render()
expect(screen.getByText(/"a":/)).toBeInTheDocument()
})
it('shows copy button when copyable', () => {
render()
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument()
})
})
```
- [ ] **Step 4: Implement CodeBlock** — `` with mono font, `--bg-inset` background, optional line numbers gutter, optional copy button, auto-JSON-pretty-print.
- [ ] **Step 5: Run tests and commit**
```bash
npx vitest run src/design-system/primitives/CodeBlock/
git add src/design-system/primitives/InfoCallout/ src/design-system/primitives/EmptyState/ src/design-system/primitives/CodeBlock/
git commit -m "feat: InfoCallout, EmptyState, CodeBlock primitives"
```
---
### Task 12: Collapsible, Tooltip
**Files:**
- Create: `src/design-system/primitives/Collapsible/{Collapsible.tsx,Collapsible.module.css,Collapsible.test.tsx}`
- Create: `src/design-system/primitives/Tooltip/{Tooltip.tsx,Tooltip.module.css}`
Note: Sparkline was moved to Task 10 (dependency of StatCard).
- [ ] **Step 1: Write Collapsible test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Collapsible } from './Collapsible'
describe('Collapsible', () => {
it('renders title', () => {
render(Content)
expect(screen.getByText('Details')).toBeInTheDocument()
})
it('hides content by default', () => {
render(Hidden content)
expect(screen.queryByText('Hidden content')).not.toBeVisible()
})
it('shows content when defaultOpen', () => {
render(Visible content)
expect(screen.getByText('Visible content')).toBeVisible()
})
it('toggles content on click', async () => {
const user = userEvent.setup()
render(Content)
await user.click(screen.getByText('Details'))
expect(screen.getByText('Content')).toBeVisible()
})
it('calls onToggle when toggled', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(Content)
await user.click(screen.getByText('Details'))
expect(onToggle).toHaveBeenCalled()
})
})
```
- [ ] **Step 2: Implement Collapsible** — controlled (`open` prop) / uncontrolled (`defaultOpen` prop), animated height transition via `max-height` + `overflow: hidden` + CSS transition. Title acts as toggle trigger with chevron indicator.
- [ ] **Step 3: Implement Tooltip** — CSS-only hover popup positioned with `position: absolute` relative to the wrapper. Four positions: top/bottom/left/right.
- [ ] **Step 4: Run tests and commit**
```bash
npx vitest run src/design-system/primitives/Collapsible/
git add src/design-system/primitives/Collapsible/ src/design-system/primitives/Tooltip/
git commit -m "feat: Collapsible and Tooltip primitives"
```
---
### Task 13: DateTimePicker, DateRangePicker
**Files:**
- Create: `src/design-system/primitives/DateTimePicker/{DateTimePicker.tsx,DateTimePicker.module.css}`
- Create: `src/design-system/primitives/DateRangePicker/{DateRangePicker.tsx,DateRangePicker.module.css,DateRangePicker.test.tsx}`
- [ ] **Step 1: Implement DateTimePicker** — styled native `` wrapped in Input-like container with same focus ring styling.
- [ ] **Step 2: Write DateRangePicker test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DateRangePicker } from './DateRangePicker'
describe('DateRangePicker', () => {
it('renders two datetime inputs', () => {
const { container } = render(
{}}
/>,
)
const inputs = container.querySelectorAll('input[type="datetime-local"]')
expect(inputs.length).toBe(2)
})
it('renders preset buttons', () => {
render(
{}}
/>,
)
expect(screen.getByText('Last 1h')).toBeInTheDocument()
expect(screen.getByText('Today')).toBeInTheDocument()
})
it('calls onChange when a preset is clicked', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
,
)
await user.click(screen.getByText('Last 1h'))
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
)
})
})
```
- [ ] **Step 3: Implement DateRangePicker** — two DateTimePicker inputs side by side with presets row above (FilterPill chips for each preset). Clicking a preset computes start/end dates and calls `onChange`.
Default presets:
```ts
const DEFAULT_PRESETS = [
{ label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' },
]
```
- [ ] **Step 4: Run tests, verify build, and commit**
```bash
npx tsc --noEmit
git add src/design-system/primitives/DateTimePicker/ src/design-system/primitives/DateRangePicker/
git commit -m "feat: DateTimePicker and DateRangePicker primitives"
```
---
### Task 14: Primitives Barrel Export
**Files:**
- Create: `src/design-system/primitives/index.ts`
- [ ] **Step 1: Create barrel export**
```ts
export { Avatar } from './Avatar/Avatar'
export { Badge } from './Badge/Badge'
export { Button } from './Button/Button'
export { Card } from './Card/Card'
export { Checkbox } from './Checkbox/Checkbox'
export { CodeBlock } from './CodeBlock/CodeBlock'
export { Collapsible } from './Collapsible/Collapsible'
export { DateTimePicker } from './DateTimePicker/DateTimePicker'
export { DateRangePicker } from './DateRangePicker/DateRangePicker'
export { EmptyState } from './EmptyState/EmptyState'
export { FilterPill } from './FilterPill/FilterPill'
export { InfoCallout } from './InfoCallout/InfoCallout'
export { Input } from './Input/Input'
export { KeyboardHint } from './KeyboardHint/KeyboardHint'
export { MonoText } from './MonoText/MonoText'
export { SectionHeader } from './SectionHeader/SectionHeader'
export { Select } from './Select/Select'
export { Sparkline } from './Sparkline/Sparkline'
export { Spinner } from './Spinner/Spinner'
export { StatCard } from './StatCard/StatCard'
export { StatusDot } from './StatusDot/StatusDot'
export { Tag } from './Tag/Tag'
export { Toggle } from './Toggle/Toggle'
export { Tooltip } from './Tooltip/Tooltip'
```
- [ ] **Step 2: Run all primitive tests**
```bash
npx vitest run src/design-system/primitives/
```
Expected: All tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/design-system/primitives/index.ts
git commit -m "feat: primitives barrel export"
```
---
## Phase 3: Composites
### Task 15: Breadcrumb + Tabs
**Files:**
- Create: `src/design-system/composites/Breadcrumb/{Breadcrumb.tsx,Breadcrumb.module.css}`
- Create: `src/design-system/composites/Tabs/{Tabs.tsx,Tabs.module.css,Tabs.test.tsx}`
- [ ] **Step 1: Implement Breadcrumb** — `nav > ol > li` with `/` separators, last item bold. Reference mock lines 412-421.
- [ ] **Step 2: Write Tabs test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Tabs } from './Tabs'
describe('Tabs', () => {
const tabs = [
{ label: 'All', count: 14, value: 'all' },
{ label: 'Executions', count: 8, value: 'executions' },
{ label: 'Routes', count: 3, value: 'routes' },
]
it('renders all tab labels', () => {
render( {}} />)
expect(screen.getByText('All')).toBeInTheDocument()
expect(screen.getByText('Executions')).toBeInTheDocument()
})
it('shows count badges', () => {
render( {}} />)
expect(screen.getByText('14')).toBeInTheDocument()
})
it('calls onChange with tab value', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render()
await user.click(screen.getByText('Routes'))
expect(onChange).toHaveBeenCalledWith('routes')
})
})
```
- [ ] **Step 3: Implement Tabs** — horizontal bar with underline active indicator, optional count badges.
- [ ] **Step 4: Run tests and commit**
```bash
npx vitest run src/design-system/composites/Tabs/
git add src/design-system/composites/Breadcrumb/ src/design-system/composites/Tabs/
git commit -m "feat: Breadcrumb and Tabs composites"
```
---
### Task 16: MenuItem + Dropdown
**Files:**
- Create: `src/design-system/composites/MenuItem/{MenuItem.tsx,MenuItem.module.css}`
- Create: `src/design-system/composites/Dropdown/{Dropdown.tsx,Dropdown.module.css,Dropdown.test.tsx}`
- [ ] **Step 1: Implement MenuItem** — sidebar nav item with StatusDot + label + meta + count. Reference mock lines 270-312 for exact CSS (left border active state, hover, indentation).
- [ ] **Step 2: Write Dropdown test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Dropdown } from './Dropdown'
const items = [
{ label: 'Edit', onClick: vi.fn() },
{ label: 'Delete', onClick: vi.fn() },
]
describe('Dropdown', () => {
it('does not show menu initially', () => {
render(Actions} items={items} />)
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
})
it('shows menu on trigger click', async () => {
const user = userEvent.setup()
render(Actions} items={items} />)
await user.click(screen.getByText('Actions'))
expect(screen.getByText('Edit')).toBeInTheDocument()
})
it('closes on Esc', async () => {
const user = userEvent.setup()
render(Actions} items={items} />)
await user.click(screen.getByText('Actions'))
await user.keyboard('{Escape}')
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
})
it('calls item onClick when clicked', async () => {
const user = userEvent.setup()
render(Actions} items={items} />)
await user.click(screen.getByText('Actions'))
await user.click(screen.getByText('Edit'))
expect(items[0].onClick).toHaveBeenCalled()
})
})
```
- [ ] **Step 3: Implement Dropdown** — trigger element + floating `` menu. Toggle on click, close on outside click or Esc. Items with optional icons and dividers.
- [ ] **Step 4: Run tests and commit**
```bash
git add src/design-system/composites/MenuItem/ src/design-system/composites/Dropdown/
git commit -m "feat: MenuItem and Dropdown composites"
```
---
### Task 17: Modal + DetailPanel
**Files:**
- Create: `src/design-system/composites/Modal/{Modal.tsx,Modal.module.css,Modal.test.tsx}`
- Create: `src/design-system/composites/DetailPanel/{DetailPanel.tsx,DetailPanel.module.css}`
- [ ] **Step 1: Write Modal test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Modal } from './Modal'
describe('Modal', () => {
it('renders children when open', () => {
render( {}}>Content)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('does not render when closed', () => {
render( {}}>Content)
expect(screen.queryByText('Content')).not.toBeInTheDocument()
})
it('renders title when provided', () => {
render( {}} title="My Modal">Content)
expect(screen.getByText('My Modal')).toBeInTheDocument()
})
it('calls onClose on Esc', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(Content)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('calls onClose on backdrop click', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(Content)
const backdrop = screen.getByTestId('modal-backdrop')
await user.click(backdrop)
expect(onClose).toHaveBeenCalled()
})
})
```
- [ ] **Step 2: Implement Modal** — overlay with backdrop (clicks close), centered Card content, close on Esc. Uses `createPortal` to render at document body. Size variants: `sm` (400px), `md` (560px), `lg` (720px). Add `data-testid="modal-backdrop"` to the backdrop element.
- [ ] **Step 3: Implement DetailPanel** — 400px right-side sliding panel. Animated `slideInRight`. Header with title + close button, Tabs for content switching, bottom action bar. Reference mock lines 1078-1179 for CSS.
- [ ] **Step 4: Run tests and commit**
```bash
npx vitest run src/design-system/composites/Modal/
git add src/design-system/composites/Modal/ src/design-system/composites/DetailPanel/
git commit -m "feat: Modal and DetailPanel composites"
```
```bash
git add src/design-system/composites/Modal/ src/design-system/composites/DetailPanel/
git commit -m "feat: Modal and DetailPanel composites"
```
---
### Task 18: FilterBar + ShortcutsBar
**Files:**
- Create: `src/design-system/composites/FilterBar/{FilterBar.tsx,FilterBar.module.css}`
- Create: `src/design-system/composites/ShortcutsBar/{ShortcutsBar.tsx,ShortcutsBar.module.css}`
- [ ] **Step 1: Implement FilterBar** — composable row of: Input (search) + separator + FilterPill group + active filter Tags below. Reference mock lines 614-773.
- [ ] **Step 2: Implement ShortcutsBar** — fixed bottom-right position, horizontal row of `KeyboardHint + label` pairs. Reference mock lines 1315-1346.
- [ ] **Step 3: Commit**
```bash
git add src/design-system/composites/FilterBar/ src/design-system/composites/ShortcutsBar/
git commit -m "feat: FilterBar and ShortcutsBar composites"
```
---
### Task 19: DataTable
**Files:**
- Create: `src/design-system/composites/DataTable/{DataTable.tsx,DataTable.module.css,DataTable.test.tsx,types.ts}`
This is one of the most complex composites. Dedicated task.
- [ ] **Step 1: Define types**
Create `src/design-system/composites/DataTable/types.ts`:
```ts
import type { ReactNode } from 'react'
export interface Column {
key: string
header: string
width?: string
sortable?: boolean
render?: (value: unknown, row: T) => ReactNode
}
export interface DataTableProps {
columns: Column[]
data: T[]
onRowClick?: (row: T) => void
selectedId?: string
sortable?: boolean
pageSize?: number
pageSizeOptions?: number[]
rowAccent?: (row: T) => 'error' | 'warning' | undefined
expandedContent?: (row: T) => ReactNode | null
}
```
- [ ] **Step 2: Write DataTable test**
```tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DataTable } from './DataTable'
const columns = [
{ key: 'name', header: 'Name' },
{ key: 'status', header: 'Status' },
]
const data = [
{ id: '1', name: 'Route A', status: 'ok' },
{ id: '2', name: 'Route B', status: 'error' },
{ id: '3', name: 'Route C', status: 'ok' },
]
describe('DataTable', () => {
it('renders column headers', () => {
render()
expect(screen.getByText('Name')).toBeInTheDocument()
expect(screen.getByText('Status')).toBeInTheDocument()
})
it('renders all data rows', () => {
render()
expect(screen.getByText('Route A')).toBeInTheDocument()
expect(screen.getByText('Route C')).toBeInTheDocument()
})
it('calls onRowClick when a row is clicked', async () => {
const onRowClick = vi.fn()
const user = userEvent.setup()
render()
await user.click(screen.getByText('Route B'))
expect(onRowClick).toHaveBeenCalledWith(data[1])
})
it('highlights selected row', () => {
const { container } = render(
,
)
const rows = container.querySelectorAll('tr')
expect(rows[2]).toHaveClass('selected') // row index 2 = data[1]
})
it('sorts when header clicked', async () => {
const user = userEvent.setup()
render()
await user.click(screen.getByText('Name'))
// After click, should show sort indicator
expect(screen.getByText('Name').closest('th')).toHaveAttribute('aria-sort')
})
})
```
- [ ] **Step 3: Implement DataTable** — `` with: sortable headers (click toggles asc/desc, arrow icon), 40px compact rows, row selection class, optional left accent border via `rowAccent`, optional `expandedContent` below row, pagination bar at bottom with page info + page size `