Merge branch 'feature/design-system-gap-fill'

This commit is contained in:
hsiegeln
2026-03-18 15:54:32 +01:00
56 changed files with 6084 additions and 1 deletions

44
CLAUDE.md Normal file
View File

@@ -0,0 +1,44 @@
# Cameleer3 Design System
## Before Building UI
Always read `COMPONENT_GUIDE.md` before building any UI feature. It contains decision trees for choosing the right component, composition patterns, and the full component index.
## Project Structure
- `src/design-system/primitives/` — atomic UI components (Button, Input, Badge, etc.)
- `src/design-system/composites/` — composed components (DataTable, Modal, Toast, etc.)
- `src/design-system/layout/` — page-level layout (AppShell, Sidebar, TopBar)
- `src/design-system/providers/` — ThemeProvider
- `src/design-system/tokens.css` — all design tokens (colors, spacing, typography)
- `src/pages/` — application pages
- `src/pages/Inventory/` — component showcase at `/inventory`
## Conventions
### Styling
- CSS Modules only — import as `import styles from './Component.module.css'`
- Use tokens from `tokens.css` — never hardcode hex colors
- All colors via CSS custom properties — supports light/dark via `[data-theme="dark"]`
- No inline styles except dynamic values (width from props, etc.)
### Components
- `forwardRef` on all form controls (Input, Textarea, Select, Checkbox, Toggle, Label)
- Every component accepts a `className` prop
- Semantic color variants: `'success' | 'warning' | 'error'` pattern
- Barrel exports: `src/design-system/primitives/index.ts` and `src/design-system/composites/index.ts`
### Testing
- Vitest + React Testing Library + happy-dom
- Tests co-located: `Component.test.tsx` next to `Component.tsx`
- Run: `npx vitest run` (all) or `npx vitest run src/path/to/Component` (single)
- Wrap in `<ThemeProvider>` when component uses `useTheme()` or `hashColor()`
### Import Paths
```tsx
import { Button, Input } from '../design-system/primitives'
import { Modal, DataTable } from '../design-system/composites'
import type { Column } from '../design-system/composites'
import { AppShell } from '../design-system/layout/AppShell'
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
```

218
COMPONENT_GUIDE.md Normal file
View File

@@ -0,0 +1,218 @@
# Cameleer3 Component Guide
> This file is for Claude Code to reference when building UI features.
> Keep it up to date when components are added or changed.
## Quick Decision Trees
### "I need to show a message to the user"
- Inline contextual note → **InfoCallout**
- Page-level attention banner → **Alert**
- Temporary non-blocking feedback → **Toast** (via `useToast`)
- Destructive action confirmation → **AlertDialog**
- Generic dialog with custom content → **Modal**
### "I need a form input"
- Single line text → **Input**
- Multiline text → **Textarea**
- On/off toggle → **Toggle**
- Yes/no with label → **Checkbox**
- One of N options (≤5) → **RadioGroup** + **RadioItem**
- One of N options (>5) → **Select**
- Date/time → **DateTimePicker**
- Date range → **DateRangePicker**
- Wrap any input with label/error/hint → **FormField**
### "I need to show loading state"
- Full component placeholder → **Skeleton** (text, circular, or rectangular)
- Inline spinner → **Spinner** (sm, md, lg)
- Operation progress → **ProgressBar** (determinate or indeterminate)
### "I need to show status"
- Dot indicator → **StatusDot** (live, stale, dead, success, warning, error, running)
- Labeled status → **Badge** with semantic color
- Removable label → **Tag**
### "I need navigation"
- App-level sidebar nav → **Sidebar** (via AppShell)
- Breadcrumb trail → **Breadcrumb**
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
- Hierarchical tree navigation → **TreeView**
### "I need floating content"
- Tooltip on hover → **Tooltip**
- Click-triggered panel → **Popover**
- Action menu → **Dropdown**
- Full-screen search/command → **CommandPalette**
### "I need to display data"
- Key metrics → **StatCard** (with optional sparkline/trend)
- Tabular data → **DataTable** (sortable, paginated)
- Time series → **LineChart**, **AreaChart**
- Categorical comparison → **BarChart**
- Inline trend → **Sparkline**
- Event log → **EventFeed**
- Processing pipeline → **ProcessorTimeline**
### "I need to organize content"
- Collapsible sections (standalone) → **Collapsible**
- Multiple collapsible sections (one/many open) → **Accordion**
- Tabbed content → **Tabs**
- Side panel inspector → **DetailPanel**
- Section with title + action → **SectionHeader**
- Empty content placeholder → **EmptyState**
- Grouped content box → **Card** (with optional accent)
### "I need to display text"
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
- Monospace inline text → **MonoText**
- Keyboard shortcut hint → **KeyboardHint**
### "I need to show people/users"
- Single user avatar → **Avatar**
- Stacked user avatars → **AvatarGroup**
### "I need filtering"
- Filter pill/chip → **FilterPill**
- Full filter bar with search → **FilterBar**
## Composition Patterns
### Standard page layout
```
AppShell → Sidebar + TopBar + main content + optional DetailPanel
```
### Data page pattern
```
FilterBar (top)
→ DataTable (center, with built-in sorting + pagination)
→ optional DetailPanel (right slide, on row click)
```
### Form layout
```
FormField wraps any input (Input, Textarea, Select, RadioGroup, etc.)
provides: label, required indicator, hint text, error message
gap: 6px between label and input, 4px to hint/error
```
### KPI dashboard
```
Row of StatCard components (each with optional Sparkline and trend)
Below: charts (AreaChart, LineChart, BarChart)
```
### Detail/inspector pattern
```
DetailPanel (right slide) with Tabs for sections
Each tab: Cards with data, CodeBlock for payloads,
ProcessorTimeline for execution flow
```
### Feedback flow
```
User action → Toast (success/error feedback)
Destructive action → AlertDialog (confirmation) → Toast (result)
```
### Tree navigation pattern
```
TreeView for hierarchical data (Application → Routes → Processors)
onSelect navigates or opens DetailPanel
```
## Component Index
| Component | Layer | When to use |
|-----------|-------|-------------|
| Accordion | composite | Multiple collapsible sections, single or multi-open mode |
| Alert | primitive | Page-level attention banner with variant colors |
| AlertDialog | composite | Confirmation dialog for destructive/important actions |
| AreaChart | composite | Time series visualization with filled area |
| Avatar | primitive | User representation with initials and color |
| AvatarGroup | composite | Stacked overlapping avatars with overflow count |
| Badge | primitive | Labeled status indicator with semantic colors |
| BarChart | composite | Categorical data comparison, optional stacking |
| Breadcrumb | composite | Navigation path showing current location |
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
| Card | primitive | Content container with optional accent border |
| Checkbox | primitive | Boolean input with label |
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
| Collapsible | primitive | Single expand/collapse section |
| CommandPalette | composite | Full-screen search and command interface |
| DataTable | composite | Sortable, paginated data table with row actions |
| DateRangePicker | primitive | Date range selection with presets |
| DateTimePicker | primitive | Single date/time input |
| DetailPanel | composite | Slide-in side panel with tabs |
| Dropdown | composite | Action menu triggered by any element |
| EmptyState | primitive | Placeholder for empty content areas |
| EventFeed | composite | Chronological event log with severity |
| FilterBar | composite | Search + filter controls for data views |
| FilterPill | primitive | Individual filter chip (active/inactive) |
| FormField | primitive | Wrapper adding label, hint, error to any input |
| InfoCallout | primitive | Inline contextual note with variant colors |
| Input | primitive | Single-line text input with optional icon |
| KeyboardHint | primitive | Keyboard shortcut display |
| Label | primitive | Form label with optional required asterisk |
| LineChart | composite | Time series line visualization |
| MenuItem | composite | Sidebar navigation item with health/count |
| Modal | composite | Generic dialog overlay with backdrop |
| MonoText | primitive | Inline monospace text (xs, sm, md) |
| Pagination | primitive | Page navigation controls |
| Popover | composite | Click-triggered floating panel with arrow |
| ProcessorTimeline | composite | Pipeline execution visualization |
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
| RadioItem | primitive | Individual radio option within RadioGroup |
| SectionHeader | primitive | Section title with optional action button |
| Select | primitive | Dropdown select input |
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
| Sparkline | primitive | Inline mini chart for trends |
| Spinner | primitive | Animated loading indicator |
| StatCard | primitive | KPI card with value, trend, optional sparkline |
| StatusDot | primitive | Colored dot for status indication |
| Tabs | composite | Tabbed content switcher with optional counts |
| Tag | primitive | Removable colored label |
| Textarea | primitive | Multi-line text input with resize control |
| Toast | composite | Temporary notification (via ToastProvider + useToast) |
| Toggle | primitive | On/off switch input |
| Tooltip | primitive | Hover-triggered text tooltip |
| TreeView | composite | Hierarchical tree with keyboard navigation |
### Layout Components
| Component | Purpose |
|-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| Sidebar | App navigation with apps, routes, agents sections |
| TopBar | Header bar with breadcrumb, environment, user info |
## Import Paths
```tsx
// Primitives
import { Button, Input, Badge, ... } from './design-system/primitives'
// Composites
import { DataTable, Modal, Toast, ... } from './design-system/composites'
import type { Column, SearchResult, FeedEvent, ... } from './design-system/composites'
// Layout
import { AppShell } from './design-system/layout/AppShell'
import { Sidebar } from './design-system/layout/Sidebar'
import { TopBar } from './design-system/layout/TopBar'
// Theme
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
```
## Styling Rules
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
- **Use existing tokens** from `tokens.css` — never hardcode hex colors
- **Theme support** — all colors via CSS custom properties, never hardcode light/dark
- **forwardRef** on all form controls (Input, Textarea, Select, Checkbox, Toggle, Label)
- **className prop** on every component for style overrides
- **Semantic color variants** — use `'success' | 'warning' | 'error'` pattern consistently

View File

@@ -126,6 +126,7 @@ src/
│ ├── utils/ │ ├── utils/
│ │ └── hashColor.ts # Name → HSL deterministic color │ │ └── hashColor.ts # Name → HSL deterministic color
│ ├── primitives/ │ ├── primitives/
│ │ ├── Alert/
│ │ ├── Avatar/ │ │ ├── Avatar/
│ │ │ ├── Avatar.tsx │ │ │ ├── Avatar.tsx
│ │ │ └── Avatar.module.css │ │ │ └── Avatar.module.css
@@ -139,22 +140,32 @@ src/
│ │ ├── DateRangePicker/ │ │ ├── DateRangePicker/
│ │ ├── EmptyState/ │ │ ├── EmptyState/
│ │ ├── FilterPill/ │ │ ├── FilterPill/
│ │ ├── FormField/
│ │ ├── InfoCallout/ │ │ ├── InfoCallout/
│ │ ├── Input/ │ │ ├── Input/
│ │ ├── KeyboardHint/ │ │ ├── KeyboardHint/
│ │ ├── Label/
│ │ ├── MonoText/ │ │ ├── MonoText/
│ │ ├── Pagination/
│ │ ├── ProgressBar/
│ │ ├── Radio/
│ │ ├── Select/ │ │ ├── Select/
│ │ ├── SectionHeader/ │ │ ├── SectionHeader/
│ │ ├── Skeleton/
│ │ ├── Sparkline/ │ │ ├── Sparkline/
│ │ ├── Spinner/ │ │ ├── Spinner/
│ │ ├── StatCard/ │ │ ├── StatCard/
│ │ ├── StatusDot/ │ │ ├── StatusDot/
│ │ ├── Tag/ │ │ ├── Tag/
│ │ ├── Textarea/
│ │ ├── Toggle/ │ │ ├── Toggle/
│ │ ├── Tooltip/ │ │ ├── Tooltip/
│ │ └── index.ts # Barrel export │ │ └── index.ts # Barrel export
│ ├── composites/ │ ├── composites/
│ │ ├── Accordion/
│ │ ├── AlertDialog/
│ │ ├── AreaChart/ │ │ ├── AreaChart/
│ │ ├── AvatarGroup/
│ │ ├── BarChart/ │ │ ├── BarChart/
│ │ ├── Breadcrumb/ │ │ ├── Breadcrumb/
│ │ ├── CommandPalette/ │ │ ├── CommandPalette/
@@ -166,9 +177,12 @@ src/
│ │ ├── LineChart/ │ │ ├── LineChart/
│ │ ├── MenuItem/ │ │ ├── MenuItem/
│ │ ├── Modal/ │ │ ├── Modal/
│ │ ├── Popover/
│ │ ├── ProcessorTimeline/ │ │ ├── ProcessorTimeline/
│ │ ├── ShortcutsBar/ │ │ ├── ShortcutsBar/
│ │ ├── Tabs/ │ │ ├── Tabs/
│ │ ├── Toast/
│ │ ├── TreeView/
│ │ └── index.ts │ │ └── index.ts
│ ├── layout/ │ ├── layout/
│ │ ├── AppShell/ │ │ ├── AppShell/
@@ -182,7 +196,8 @@ src/
│ ├── Metrics/ │ ├── Metrics/
│ ├── RouteDetail/ │ ├── RouteDetail/
│ ├── ExchangeDetail/ │ ├── ExchangeDetail/
── AgentHealth/ ── AgentHealth/
│ └── Inventory/ # Component showcase at /inventory
├── mocks/ # Static TypeScript mock data ├── mocks/ # Static TypeScript mock data
│ ├── exchanges.ts │ ├── exchanges.ts
│ ├── routes.ts │ ├── routes.ts
@@ -351,6 +366,57 @@ All CSS custom properties for both themes. Light values sourced from `mock-v2-li
- Color inherits from parent or explicit prop - Color inherits from parent or explicit prop
- Props: `data: number[]`, `color?: string`, `width?: number`, `height?: number`, `strokeWidth?: number` - Props: `data: number[]`, `color?: string`, `width?: number`, `height?: number`, `strokeWidth?: number`
#### Alert
- Inline page-level attention banner with variant colors
- Left accent border (4px), variant background color, default icon per variant
- Variants: `info`, `success`, `warning`, `error`
- ARIA: `role="alert"` for error/warning, `role="status"` for info/success
- Props: `variant?: 'info' | 'success' | 'warning' | 'error'`, `title?: string`, `children`, `dismissible?: boolean`, `onDismiss?`, `icon?: ReactNode`
#### FormField
- Wrapper component: Label + children slot + hint text + error text
- Error replaces hint when both present; adds `.error` class to wrapper
- Props: `label?: string`, `htmlFor?: string`, `required?: boolean`, `error?: string`, `hint?: string`, `children: ReactNode`
#### Label
- Form label with optional required asterisk (red `*`)
- Font: `--font-body`, 12px, `--text-primary`, `font-weight: 500`
- Uses `forwardRef`
- Props: extends `LabelHTMLAttributes` + `required?: boolean`
#### Pagination
- Page navigation: `< 1 ... 4 [5] 6 ... 20 >`
- Always shows first, last, and siblingCount pages around current
- Active page: `--amber` background, white text
- Props: `page: number`, `totalPages: number`, `onPageChange`, `siblingCount?: number`
#### ProgressBar
- Track with animated fill bar
- Determinate (value 0-100) or indeterminate (shimmer animation)
- Variants: primary, success, warning, error, running
- Sizes: sm (4px height), md (8px height)
- ARIA: `role="progressbar"`, `aria-valuenow`, `aria-valuemin`, `aria-valuemax`
- Props: `value?: number`, `variant?`, `size?: 'sm' | 'md'`, `indeterminate?: boolean`, `label?: string`
#### Radio (RadioGroup + RadioItem)
- Single-select option group using React Context
- Visual pattern matches Checkbox: hidden native input, custom circle with amber fill
- Circle: 15px, `border-radius: 50%`, checked inner dot 7px
- RadioGroup: `name`, `value`, `onChange`, `orientation?: 'vertical' | 'horizontal'`
- RadioItem: `value`, `label: ReactNode`, `disabled?: boolean`
#### Skeleton
- Loading placeholder with shimmer animation
- Variants: `text` (12px bars), `circular` (50% radius, 40x40 default), `rectangular` (full width, 80px default)
- Multi-line text: `lines` prop, last line at 70% width
- Props: `variant?`, `width?`, `height?`, `lines?: number`
#### Textarea
- Multi-line text input matching Input visual styling
- Focus ring: amber border + amber-bg box-shadow
- Uses `forwardRef`
- Props: extends `TextareaHTMLAttributes` + `resize?: 'vertical' | 'horizontal' | 'none' | 'both'`
### 5.3 Composites ### 5.3 Composites
#### Breadcrumb #### Breadcrumb
@@ -469,6 +535,52 @@ All CSS custom properties for both themes. Light values sourced from `mock-v2-li
- Each hint: `KeyboardHint` + description text - Each hint: `KeyboardHint` + description text
- Props: `shortcuts: { keys: string; label: string }[]` - Props: `shortcuts: { keys: string; label: string }[]`
#### Accordion
- Multiple collapsible sections managed as a group
- Uses Collapsible internally in controlled mode
- Single mode (default): opening one closes others. Multiple mode: independent.
- Container: `--border-subtle` border, `--radius-md`, sections separated by dividers
- Props: `items: AccordionItem[]`, `multiple?: boolean`
- `AccordionItem`: `{ id: string, title: ReactNode, content: ReactNode, defaultOpen?: boolean }`
#### AlertDialog
- Confirmation dialog for destructive/important actions, built on Modal
- Fixed layout: icon area + title + description + button row
- Variant-colored icon circle (danger=✕, warning=⚠, info=)
- Auto-focuses Cancel button (safe default for destructive dialogs)
- Props: `open`, `onClose`, `onConfirm`, `title`, `description`, `variant?: 'danger' | 'warning' | 'info'`, `loading?`
#### AvatarGroup
- Renders overlapping Avatar components with overflow count
- Overlap: negative margin-left per size. Ring: 2px solid `--bg-surface`
- Overflow circle: `--bg-inset` background, `--font-mono` 10px, `+N` text
- Props: `names: string[]`, `max?: number`, `size?: 'sm' | 'md' | 'lg'`
#### Popover
- Click-triggered floating panel with CSS triangle arrow
- Positions: top, bottom, left, right. Alignment: start, center, end
- Closes on outside click or Esc. Uses `createPortal`.
- Animation: opacity + scale, 150ms ease-out
- Props: `trigger: ReactNode`, `content: ReactNode`, `position?`, `align?`
#### Toast (ToastProvider + useToast)
- App-level toast notification system via React Context
- `ToastProvider` wraps the app; `useToast()` returns `{ toast, dismiss }`
- `toast({ title, description?, variant?, duration? })` → returns id
- Container: fixed bottom-right, z-index 1100, max 5 visible
- Each toast: left accent border, variant icon, auto-dismiss (default 5000ms)
- Slide-in animation from right, fade-out on dismiss
#### TreeView
- Hierarchical tree with recursive rendering and keyboard navigation
- Controlled or uncontrolled expand state
- Selected node: amber background + left border
- Keyboard: arrows (navigate), left/right (collapse/expand), Enter (select), Home/End
- Connector lines for visual hierarchy
- ARIA: `role="tree"`, `role="treeitem"`, `aria-expanded`
- Props: `nodes: TreeNode[]`, `onSelect?`, `selectedId?`, `expandedIds?`, `onToggle?`
- `TreeNode`: `{ id, label, icon?, children?, meta? }`
### 5.4 Layout ### 5.4 Layout
#### AppShell #### AppShell
@@ -509,6 +621,7 @@ Pages compose design system components with mock data. Each page is a route.
| `/routes/:id` | RouteDetail | Per-route stats from `mock-v3-route-detail` | | `/routes/:id` | RouteDetail | Per-route stats from `mock-v3-route-detail` |
| `/exchanges/:id` | ExchangeDetail | Message inspector from `mock-v3-exchange-detail` | | `/exchanges/:id` | ExchangeDetail | Message inspector from `mock-v3-exchange-detail` |
| `/agents` | AgentHealth | Agent monitoring from `mock-v3-agent-health` | | `/agents` | AgentHealth | Agent monitoring from `mock-v3-agent-health` |
| `/inventory` | Inventory | Component showcase with live interactive demos |
Pages are built after the design system layer is complete. Pages are built after the design system layer is complete.

View File

@@ -4,6 +4,7 @@ import { Metrics } from './pages/Metrics/Metrics'
import { RouteDetail } from './pages/RouteDetail/RouteDetail' import { RouteDetail } from './pages/RouteDetail/RouteDetail'
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail' import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
import { AgentHealth } from './pages/AgentHealth/AgentHealth' import { AgentHealth } from './pages/AgentHealth/AgentHealth'
import { Inventory } from './pages/Inventory/Inventory'
export default function App() { export default function App() {
return ( return (
@@ -13,6 +14,7 @@ export default function App() {
<Route path="/routes/:id" element={<RouteDetail />} /> <Route path="/routes/:id" element={<RouteDetail />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} /> <Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/agents" element={<AgentHealth />} /> <Route path="/agents" element={<AgentHealth />} />
<Route path="/inventory" element={<Inventory />} />
</Routes> </Routes>
) )
} }

View File

@@ -0,0 +1,28 @@
.root {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
/* Override Collapsible's own border and radius for all items */
.item {
border: none;
border-radius: 0;
}
/* Divider between items */
.itemDivider {
border-top: 1px solid var(--border-subtle);
}
/* Restore border-radius on outer corners of first item */
.itemFirst {
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
}
/* Restore border-radius on outer corners of last item */
.itemLast {
border-bottom-left-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
}

View File

@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Accordion } from './Accordion'
import type { AccordionItem } from './Accordion'
const items: AccordionItem[] = [
{ id: 'a', title: 'Section A', content: 'Content A' },
{ id: 'b', title: 'Section B', content: 'Content B' },
{ id: 'c', title: 'Section C', content: 'Content C' },
]
describe('Accordion', () => {
it('renders all item titles', () => {
render(<Accordion items={items} />)
expect(screen.getByText('Section A')).toBeInTheDocument()
expect(screen.getByText('Section B')).toBeInTheDocument()
expect(screen.getByText('Section C')).toBeInTheDocument()
})
it('does not show content by default when no defaultOpen', () => {
render(<Accordion items={items} />)
expect(screen.queryByText('Content A')).not.toBeVisible()
expect(screen.queryByText('Content B')).not.toBeVisible()
})
it('shows content for defaultOpen items', () => {
const withDefault: AccordionItem[] = [
{ id: 'a', title: 'Section A', content: 'Content A', defaultOpen: true },
{ id: 'b', title: 'Section B', content: 'Content B' },
]
render(<Accordion items={withDefault} />)
expect(screen.getByText('Content A')).toBeVisible()
expect(screen.queryByText('Content B')).not.toBeVisible()
})
describe('single mode (default)', () => {
it('opens a section when its trigger is clicked', async () => {
const user = userEvent.setup()
render(<Accordion items={items} />)
await user.click(screen.getByText('Section A'))
expect(screen.getByText('Content A')).toBeVisible()
})
it('closes other sections when a new one is opened', async () => {
const user = userEvent.setup()
render(<Accordion items={items} />)
await user.click(screen.getByText('Section A'))
expect(screen.getByText('Content A')).toBeVisible()
await user.click(screen.getByText('Section B'))
expect(screen.getByText('Content B')).toBeVisible()
expect(screen.queryByText('Content A')).not.toBeVisible()
})
it('closes the open section when clicking it again', async () => {
const user = userEvent.setup()
render(<Accordion items={items} />)
await user.click(screen.getByText('Section A'))
expect(screen.getByText('Content A')).toBeVisible()
await user.click(screen.getByText('Section A'))
expect(screen.queryByText('Content A')).not.toBeVisible()
})
it('only keeps first defaultOpen in single mode when multiple defaultOpen set', () => {
const withMultiDefault: AccordionItem[] = [
{ id: 'a', title: 'Section A', content: 'Content A', defaultOpen: true },
{ id: 'b', title: 'Section B', content: 'Content B', defaultOpen: true },
]
render(<Accordion items={withMultiDefault} />)
expect(screen.getByText('Content A')).toBeVisible()
expect(screen.queryByText('Content B')).not.toBeVisible()
})
})
describe('multiple mode', () => {
it('allows multiple sections to be open simultaneously', async () => {
const user = userEvent.setup()
render(<Accordion items={items} multiple />)
await user.click(screen.getByText('Section A'))
await user.click(screen.getByText('Section B'))
expect(screen.getByText('Content A')).toBeVisible()
expect(screen.getByText('Content B')).toBeVisible()
})
it('toggles individual sections independently', async () => {
const user = userEvent.setup()
render(<Accordion items={items} multiple />)
await user.click(screen.getByText('Section A'))
await user.click(screen.getByText('Section B'))
await user.click(screen.getByText('Section A'))
expect(screen.queryByText('Content A')).not.toBeVisible()
expect(screen.getByText('Content B')).toBeVisible()
})
it('respects multiple defaultOpen items in multiple mode', () => {
const withMultiDefault: AccordionItem[] = [
{ id: 'a', title: 'Section A', content: 'Content A', defaultOpen: true },
{ id: 'b', title: 'Section B', content: 'Content B', defaultOpen: true },
]
render(<Accordion items={withMultiDefault} multiple />)
expect(screen.getByText('Content A')).toBeVisible()
expect(screen.getByText('Content B')).toBeVisible()
})
})
})

View File

@@ -0,0 +1,74 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { Collapsible } from '../../primitives/Collapsible/Collapsible'
import styles from './Accordion.module.css'
export interface AccordionItem {
id: string
title: ReactNode
content: ReactNode
defaultOpen?: boolean
}
interface AccordionProps {
items: AccordionItem[]
multiple?: boolean
className?: string
}
export function Accordion({ items, multiple = false, className }: AccordionProps) {
const [openIds, setOpenIds] = useState<Set<string>>(() => {
const initial = new Set<string>()
for (const item of items) {
if (item.defaultOpen) initial.add(item.id)
}
// In single mode, only keep the first defaultOpen item
if (!multiple && initial.size > 1) {
const first = [...initial][0]
return new Set([first])
}
return initial
})
function handleToggle(id: string, open: boolean) {
setOpenIds((prev) => {
const next = new Set(prev)
if (open) {
if (!multiple) next.clear()
next.add(id)
} else {
next.delete(id)
}
return next
})
}
return (
<div className={`${styles.root} ${className ?? ''}`}>
{items.map((item, index) => {
const isFirst = index === 0
const isLast = index === items.length - 1
const itemClass = [
styles.item,
isFirst ? styles.itemFirst : '',
isLast ? styles.itemLast : '',
index > 0 ? styles.itemDivider : '',
]
.filter(Boolean)
.join(' ')
return (
<Collapsible
key={item.id}
title={item.title}
open={openIds.has(item.id)}
onToggle={(open) => handleToggle(item.id, open)}
className={itemClass}
>
{item.content}
</Collapsible>
)
})}
</div>
)
}

View File

@@ -0,0 +1,66 @@
.content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
padding: 8px 0 4px;
font-family: var(--font-body);
}
/* Icon circle */
.iconCircle {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 4px;
}
.danger {
background: var(--error-bg);
color: var(--error);
}
.warning {
background: var(--warning-bg);
color: var(--warning);
}
.info {
background: var(--running-bg);
color: var(--running);
}
.icon {
font-size: 18px;
line-height: 1;
}
/* Text */
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
}
.description {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
/* Button row */
.buttonRow {
display: flex;
gap: 8px;
justify-content: flex-end;
width: 100%;
margin-top: 8px;
}

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AlertDialog } from './AlertDialog'
const defaultProps = {
open: true,
onClose: vi.fn(),
onConfirm: vi.fn(),
title: 'Delete item',
description: 'This action cannot be undone.',
}
describe('AlertDialog', () => {
it('renders title and description when open', () => {
render(<AlertDialog {...defaultProps} />)
expect(screen.getByText('Delete item')).toBeInTheDocument()
expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument()
})
it('does not render when closed', () => {
render(<AlertDialog {...defaultProps} open={false} />)
expect(screen.queryByText('Delete item')).not.toBeInTheDocument()
expect(screen.queryByText('This action cannot be undone.')).not.toBeInTheDocument()
})
it('renders default button labels', () => {
render(<AlertDialog {...defaultProps} />)
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
})
it('renders custom button labels', () => {
render(<AlertDialog {...defaultProps} confirmLabel="Yes, delete" cancelLabel="No, keep" />)
expect(screen.getByRole('button', { name: 'Yes, delete' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'No, keep' })).toBeInTheDocument()
})
it('calls onConfirm when confirm button is clicked', async () => {
const onConfirm = vi.fn()
const user = userEvent.setup()
render(<AlertDialog {...defaultProps} onConfirm={onConfirm} />)
await user.click(screen.getByRole('button', { name: 'Confirm' }))
expect(onConfirm).toHaveBeenCalledOnce()
})
it('calls onClose when cancel button is clicked', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<AlertDialog {...defaultProps} onClose={onClose} />)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onClose when Esc is pressed', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<AlertDialog {...defaultProps} onClose={onClose} />)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('disables both buttons when loading', () => {
render(<AlertDialog {...defaultProps} loading />)
const buttons = screen.getAllByRole('button')
// Both cancel and confirm should be disabled
for (const btn of buttons) {
expect(btn).toBeDisabled()
}
})
it('auto-focuses cancel button on open', async () => {
render(<AlertDialog {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus()
})
})
it('renders danger variant icon', () => {
render(<AlertDialog {...defaultProps} variant="danger" />)
// Icon area should be present (aria-hidden)
expect(screen.getByText('✕')).toBeInTheDocument()
})
it('renders warning variant icon', () => {
render(<AlertDialog {...defaultProps} variant="warning" />)
expect(screen.getByText('⚠')).toBeInTheDocument()
})
it('renders info variant icon', () => {
render(<AlertDialog {...defaultProps} variant="info" />)
expect(screen.getByText('')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,86 @@
import { useEffect, useRef } from 'react'
import { Modal } from '../Modal/Modal'
import { Button } from '../../primitives/Button/Button'
import styles from './AlertDialog.module.css'
interface AlertDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
loading?: boolean
className?: string
}
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = {
danger: '✕',
warning: '⚠',
info: '',
}
export function AlertDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'danger',
loading = false,
className,
}: AlertDialogProps) {
const cancelWrapRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
if (open) {
// Defer to allow Modal portal to render first
const id = setTimeout(() => {
const btn = cancelWrapRef.current?.querySelector('button')
btn?.focus()
}, 0)
return () => clearTimeout(id)
}
}, [open])
const confirmButtonVariant = variant === 'danger' ? 'danger' : 'primary'
return (
<Modal open={open} onClose={onClose} size="sm" className={className}>
<div className={styles.content}>
<div className={`${styles.iconCircle} ${styles[variant]}`} aria-hidden="true">
<span className={styles.icon}>{variantIcons[variant]}</span>
</div>
<h2 className={styles.title}>{title}</h2>
<p className={styles.description}>{description}</p>
<div className={styles.buttonRow}>
<span ref={cancelWrapRef}>
<Button
variant="secondary"
onClick={onClose}
disabled={loading}
type="button"
>
{cancelLabel}
</Button>
</span>
<Button
variant={confirmButtonVariant}
onClick={onConfirm}
loading={loading}
disabled={loading}
type="button"
>
{confirmLabel}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,58 @@
.group {
display: inline-flex;
align-items: center;
}
/* Each avatar (except the first) overlaps the previous */
.avatar {
margin-left: -8px;
outline: 2px solid var(--bg-surface);
border-radius: 50%;
}
.first {
margin-left: 0;
}
/* Size-specific overlap amounts */
.sm .avatar {
margin-left: -6px;
}
.lg .avatar {
margin-left: -10px;
}
/* Overflow circle */
.overflow {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: var(--bg-inset);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
flex-shrink: 0;
margin-left: -8px;
outline: 2px solid var(--bg-surface);
}
.overflow_sm {
width: 24px;
height: 24px;
margin-left: -6px;
}
.overflow_md {
width: 28px;
height: 28px;
margin-left: -8px;
}
.overflow_lg {
width: 40px;
height: 40px;
margin-left: -10px;
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ThemeProvider } from '../../providers/ThemeProvider'
import { AvatarGroup } from './AvatarGroup'
const names = ['Alice Johnson', 'Bob Smith', 'Carol White', 'Dave Brown', 'Eve Davis']
function renderGroup(props: React.ComponentProps<typeof AvatarGroup>) {
return render(
<ThemeProvider>
<AvatarGroup {...props} />
</ThemeProvider>
)
}
describe('AvatarGroup', () => {
it('renders max avatars (default 3) when count exceeds max', () => {
renderGroup({ names })
expect(screen.getByLabelText('Alice Johnson')).toBeInTheDocument()
expect(screen.getByLabelText('Bob Smith')).toBeInTheDocument()
expect(screen.getByLabelText('Carol White')).toBeInTheDocument()
expect(screen.queryByLabelText('Dave Brown')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Eve Davis')).not.toBeInTheDocument()
})
it('shows +N overflow indicator for remaining avatars', () => {
renderGroup({ names })
// 5 names, max 3 => +2 overflow
expect(screen.getByText('+2')).toBeInTheDocument()
})
it('renders all avatars and no overflow when count <= max', () => {
renderGroup({ names: ['Alice Johnson', 'Bob Smith'], max: 3 })
expect(screen.getByLabelText('Alice Johnson')).toBeInTheDocument()
expect(screen.getByLabelText('Bob Smith')).toBeInTheDocument()
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument()
})
it('respects a custom max prop', () => {
renderGroup({ names, max: 2 })
expect(screen.getByLabelText('Alice Johnson')).toBeInTheDocument()
expect(screen.getByLabelText('Bob Smith')).toBeInTheDocument()
expect(screen.queryByLabelText('Carol White')).not.toBeInTheDocument()
// 5 names, max 2 => +3 overflow
expect(screen.getByText('+3')).toBeInTheDocument()
})
it('accepts a size prop without errors', () => {
const { container } = renderGroup({ names, size: 'lg' })
expect(container.firstChild).toBeInTheDocument()
})
it('renders all avatars when count exactly equals max', () => {
renderGroup({ names: ['Alice Johnson', 'Bob Smith', 'Carol White'], max: 3 })
expect(screen.getByLabelText('Alice Johnson')).toBeInTheDocument()
expect(screen.getByLabelText('Bob Smith')).toBeInTheDocument()
expect(screen.getByLabelText('Carol White')).toBeInTheDocument()
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,32 @@
import styles from './AvatarGroup.module.css'
import { Avatar } from '../../primitives/Avatar/Avatar'
interface AvatarGroupProps {
names: string[]
max?: number
size?: 'sm' | 'md' | 'lg'
className?: string
}
export function AvatarGroup({ names, max = 3, size = 'md', className }: AvatarGroupProps) {
const visible = names.slice(0, max)
const overflow = names.length - visible.length
return (
<span className={`${styles.group} ${styles[size]} ${className ?? ''}`}>
{visible.map((name, index) => (
<Avatar
key={`${name}-${index}`}
name={name}
size={size}
className={`${styles.avatar} ${index === 0 ? styles.first : ''}`}
/>
))}
{overflow > 0 && (
<span className={`${styles.overflow} ${styles[`overflow_${size}`]}`}>
+{overflow}
</span>
)}
</span>
)
}

View File

@@ -0,0 +1,107 @@
.wrapper {
position: relative;
display: inline-block;
}
.trigger {
display: inline-flex;
cursor: pointer;
}
/* Portal-rendered content — positioned via inline style */
.content {
position: absolute;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 12px;
z-index: 500;
animation: popoverIn 150ms ease-out;
min-width: 160px;
}
@keyframes popoverIn {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* ── Arrow ───────────────────────────────────────────────── */
.arrow {
position: absolute;
width: 0;
height: 0;
}
/* Arrow pointing UP (content is below trigger) */
.arrow-bottom {
top: -8px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid var(--bg-surface);
}
/* Arrow pointing DOWN (content is above trigger) */
.arrow-top {
bottom: -8px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid var(--bg-surface);
}
/* Arrow pointing RIGHT (content is to the left of trigger) */
.arrow-left {
right: -8px;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 8px solid var(--bg-surface);
}
/* Arrow pointing LEFT (content is to the right of trigger) */
.arrow-right {
left: -8px;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-right: 8px solid var(--bg-surface);
}
/* Arrow alignment for top/bottom positioned content */
.position-bottom.align-start .arrow-bottom,
.position-top.align-start .arrow-top {
left: 12px;
}
.position-bottom.align-center .arrow-bottom,
.position-top.align-center .arrow-top {
left: 50%;
transform: translateX(-50%);
}
.position-bottom.align-end .arrow-bottom,
.position-top.align-end .arrow-top {
right: 12px;
}
/* Arrow alignment for left/right positioned content */
.position-left.align-start .arrow-left,
.position-right.align-start .arrow-right {
top: 12px;
}
.position-left.align-center .arrow-left,
.position-right.align-center .arrow-right {
top: 50%;
transform: translateY(-50%);
}
.position-left.align-end .arrow-left,
.position-right.align-end .arrow-right {
bottom: 12px;
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Popover } from './Popover'
describe('Popover', () => {
it('does not show content initially', () => {
render(
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />,
)
expect(screen.queryByText('Popover content')).not.toBeInTheDocument()
})
it('shows content on trigger click', async () => {
const user = userEvent.setup()
render(
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Popover content')).toBeInTheDocument()
})
it('toggles closed on second trigger click', async () => {
const user = userEvent.setup()
render(
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Popover content')).toBeInTheDocument()
await user.click(screen.getByText('Open'))
expect(screen.queryByText('Popover content')).not.toBeInTheDocument()
})
it('closes on Esc key', async () => {
const user = userEvent.setup()
render(
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Popover content')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(screen.queryByText('Popover content')).not.toBeInTheDocument()
})
it('closes on outside click', async () => {
const user = userEvent.setup()
render(
<div>
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />
<button>Outside</button>
</div>,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Popover content')).toBeInTheDocument()
await user.click(screen.getByText('Outside'))
expect(screen.queryByText('Popover content')).not.toBeInTheDocument()
})
it('renders content via portal into document.body', async () => {
const user = userEvent.setup()
render(
<Popover trigger={<button>Open</button>} content={<p>Popover content</p>} />,
)
await user.click(screen.getByText('Open'))
const contentEl = screen.getByTestId('popover-content')
expect(document.body.contains(contentEl)).toBe(true)
})
it('accepts position prop without error', async () => {
const user = userEvent.setup()
render(
<Popover
trigger={<button>Open</button>}
content={<p>Top content</p>}
position="top"
/>,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Top content')).toBeInTheDocument()
})
it('accepts align prop without error', async () => {
const user = userEvent.setup()
render(
<Popover
trigger={<button>Open</button>}
content={<p>Start aligned</p>}
position="bottom"
align="start"
/>,
)
await user.click(screen.getByText('Open'))
expect(screen.getByText('Start aligned')).toBeInTheDocument()
})
it('does not close when clicking inside the content panel', async () => {
const user = userEvent.setup()
render(
<Popover
trigger={<button>Open</button>}
content={<button>Inner button</button>}
/>,
)
await user.click(screen.getByText('Open'))
await user.click(screen.getByText('Inner button'))
expect(screen.getByText('Inner button')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,146 @@
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import styles from './Popover.module.css'
export interface PopoverProps {
trigger: ReactNode
content: ReactNode
position?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'center' | 'end'
className?: string
}
interface ContentStyle {
top: number
left: number
}
function getContentStyle(
triggerRect: DOMRect,
contentEl: HTMLDivElement | null,
position: 'top' | 'bottom' | 'left' | 'right',
align: 'start' | 'center' | 'end',
): ContentStyle {
const ARROW_SIZE = 8
const GAP = ARROW_SIZE + 4
const contentWidth = contentEl?.offsetWidth ?? 200
const contentHeight = contentEl?.offsetHeight ?? 100
let top = 0
let left = 0
// Main axis
if (position === 'bottom') {
top = triggerRect.bottom + window.scrollY + GAP
} else if (position === 'top') {
top = triggerRect.top + window.scrollY - contentHeight - GAP
} else if (position === 'left') {
left = triggerRect.left + window.scrollX - contentWidth - GAP
} else if (position === 'right') {
left = triggerRect.right + window.scrollX + GAP
}
// Cross axis alignment
if (position === 'top' || position === 'bottom') {
if (align === 'start') {
left = triggerRect.left + window.scrollX
} else if (align === 'center') {
left = triggerRect.left + window.scrollX + triggerRect.width / 2 - contentWidth / 2
} else {
left = triggerRect.right + window.scrollX - contentWidth
}
} else {
if (align === 'start') {
top = triggerRect.top + window.scrollY
} else if (align === 'center') {
top = triggerRect.top + window.scrollY + triggerRect.height / 2 - contentHeight / 2
} else {
top = triggerRect.bottom + window.scrollY - contentHeight
}
}
return { top, left }
}
export function Popover({
trigger,
content,
position = 'bottom',
align = 'center',
className,
}: PopoverProps) {
const [open, setOpen] = useState(false)
const [style, setStyle] = useState<ContentStyle>({ top: 0, left: 0 })
const triggerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const recalculate = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
setStyle(getContentStyle(rect, contentRef.current, position, align))
}, [position, align])
// Recalculate after content renders
useEffect(() => {
if (open) {
// Allow the DOM to paint once, then measure
const id = requestAnimationFrame(() => {
recalculate()
})
return () => cancelAnimationFrame(id)
}
}, [open, recalculate])
// Close on outside click
useEffect(() => {
if (!open) return
function handleMouseDown(e: MouseEvent) {
if (
triggerRef.current &&
!triggerRef.current.contains(e.target as Node) &&
contentRef.current &&
!contentRef.current.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleMouseDown)
return () => document.removeEventListener('mousedown', handleMouseDown)
}, [open])
// Close on Esc
useEffect(() => {
if (!open) return
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open])
return (
<div ref={triggerRef} className={`${styles.wrapper} ${className ?? ''}`}>
<div
className={styles.trigger}
onClick={() => setOpen((prev) => !prev)}
>
{trigger}
</div>
{open &&
createPortal(
<div
ref={contentRef}
className={`${styles.content} ${styles[`position-${position}`]} ${styles[`align-${align}`]}`}
style={{ top: style.top, left: style.left }}
role="dialog"
data-testid="popover-content"
>
<div className={`${styles.arrow} ${styles[`arrow-${position}`]}`} aria-hidden="true" />
{content}
</div>,
document.body,
)}
</div>
)
}

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

View File

@@ -0,0 +1,99 @@
.root {
list-style: none;
margin: 0;
padding: 0;
font-family: var(--font-body);
font-size: 14px;
color: var(--text-primary);
outline: none;
}
.children {
list-style: none;
margin: 0;
padding: 0;
}
.row {
display: flex;
align-items: center;
gap: 4px;
padding-top: 4px;
padding-bottom: 4px;
padding-right: 8px;
cursor: pointer;
border-left: 3px solid transparent;
border-radius: 0 4px 4px 0;
user-select: none;
outline: none;
position: relative;
min-height: 28px;
}
.row:hover {
background: var(--bg-hover);
}
.row:focus-visible {
outline: 2px solid var(--amber);
outline-offset: -2px;
}
.selected {
background: var(--amber-bg);
border-left-color: var(--amber);
color: var(--amber-deep);
}
.selected:hover {
background: var(--amber-bg);
}
/* Chevron slot — reserves space so leaf nodes align with parent icons */
.chevronSlot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
flex-shrink: 0;
}
.chevron {
font-size: 10px;
color: var(--text-muted);
line-height: 1;
}
.selected .chevron {
color: var(--amber-deep);
}
.icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
font-size: 14px;
}
.label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
margin-left: auto;
padding-left: 8px;
flex-shrink: 0;
white-space: nowrap;
}
.selected .meta {
color: var(--amber-deep);
opacity: 0.8;
}

View File

@@ -0,0 +1,302 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TreeView } from './TreeView'
import type { TreeNode } from './TreeView'
const nodes: TreeNode[] = [
{
id: 'root1',
label: 'Root One',
children: [
{
id: 'child1',
label: 'Child One',
meta: 'meta-a',
children: [
{ id: 'grandchild1', label: 'Grandchild One' },
{ id: 'grandchild2', label: 'Grandchild Two' },
],
},
{ id: 'child2', label: 'Child Two', icon: <span></span> },
],
},
{
id: 'root2',
label: 'Root Two',
},
]
describe('TreeView', () => {
describe('rendering', () => {
it('renders top-level nodes', () => {
render(<TreeView nodes={nodes} />)
expect(screen.getByText('Root One')).toBeInTheDocument()
expect(screen.getByText('Root Two')).toBeInTheDocument()
})
it('does not render children of collapsed nodes', () => {
render(<TreeView nodes={nodes} />)
expect(screen.queryByText('Child One')).not.toBeInTheDocument()
expect(screen.queryByText('Child Two')).not.toBeInTheDocument()
})
it('renders icon when provided', () => {
render(<TreeView nodes={nodes} expandedIds={['root1']} onToggle={() => {}} />)
expect(screen.getByText('★')).toBeInTheDocument()
})
it('renders meta text when provided', () => {
render(<TreeView nodes={nodes} expandedIds={['root1']} onToggle={() => {}} />)
expect(screen.getByText('meta-a')).toBeInTheDocument()
})
it('shows chevron on parent nodes', () => {
render(<TreeView nodes={nodes} />)
// Root One has children so should have a chevron
const treeitem = screen.getByRole('treeitem', { name: /Root One/i })
expect(treeitem.textContent).toContain('▸')
})
it('shows no chevron on leaf nodes', () => {
render(<TreeView nodes={nodes} />)
// Root Two is a leaf
const treeitem = screen.getByRole('treeitem', { name: /Root Two/i })
expect(treeitem.textContent).not.toContain('▸')
expect(treeitem.textContent).not.toContain('▾')
})
})
describe('ARIA attributes', () => {
it('has role="tree" on root element', () => {
render(<TreeView nodes={nodes} />)
expect(screen.getByRole('tree')).toBeInTheDocument()
})
it('has role="treeitem" on each node row', () => {
render(<TreeView nodes={nodes} />)
const items = screen.getAllByRole('treeitem')
expect(items.length).toBeGreaterThanOrEqual(2)
})
it('sets aria-expanded="false" on collapsed parent nodes', () => {
render(<TreeView nodes={nodes} />)
const treeitem = screen.getByRole('treeitem', { name: /Root One/i })
expect(treeitem).toHaveAttribute('aria-expanded', 'false')
})
it('sets aria-expanded="true" on expanded parent nodes', () => {
render(<TreeView nodes={nodes} expandedIds={['root1']} onToggle={() => {}} />)
const treeitem = screen.getByRole('treeitem', { name: /Root One/i })
expect(treeitem).toHaveAttribute('aria-expanded', 'true')
})
it('does not set aria-expanded on leaf nodes', () => {
render(<TreeView nodes={nodes} />)
const treeitem = screen.getByRole('treeitem', { name: /Root Two/i })
expect(treeitem).not.toHaveAttribute('aria-expanded')
})
it('sets aria-selected on selected node', () => {
render(<TreeView nodes={nodes} selectedId="root2" />)
const treeitem = screen.getByRole('treeitem', { name: /Root Two/i })
expect(treeitem).toHaveAttribute('aria-selected', 'true')
})
})
describe('expand / collapse (uncontrolled)', () => {
it('expands a collapsed parent on click', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
await user.click(screen.getByRole('treeitem', { name: /Root One/i }))
expect(screen.getByText('Child One')).toBeInTheDocument()
expect(screen.getByText('Child Two')).toBeInTheDocument()
})
it('collapses an expanded parent on second click', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
await user.click(rootOne)
expect(screen.getByText('Child One')).toBeInTheDocument()
await user.click(rootOne)
expect(screen.queryByText('Child One')).not.toBeInTheDocument()
})
it('shows expanded chevron when expanded', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
await user.click(rootOne)
expect(rootOne.textContent).toContain('▾')
})
})
describe('expand / collapse (controlled)', () => {
it('renders children based on controlled expandedIds', () => {
render(
<TreeView nodes={nodes} expandedIds={['root1']} onToggle={() => {}} />,
)
expect(screen.getByText('Child One')).toBeInTheDocument()
})
it('calls onToggle with node id when parent is clicked', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(<TreeView nodes={nodes} expandedIds={[]} onToggle={onToggle} />)
await user.click(screen.getByRole('treeitem', { name: /Root One/i }))
expect(onToggle).toHaveBeenCalledWith('root1')
})
it('does not call onToggle when leaf is clicked', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(<TreeView nodes={nodes} expandedIds={[]} onToggle={onToggle} />)
await user.click(screen.getByRole('treeitem', { name: /Root Two/i }))
expect(onToggle).not.toHaveBeenCalled()
})
})
describe('selection', () => {
it('calls onSelect with node id when a node is clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
render(<TreeView nodes={nodes} onSelect={onSelect} />)
await user.click(screen.getByRole('treeitem', { name: /Root Two/i }))
expect(onSelect).toHaveBeenCalledWith('root2')
})
it('calls onSelect for parent nodes too', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
render(<TreeView nodes={nodes} onSelect={onSelect} />)
await user.click(screen.getByRole('treeitem', { name: /Root One/i }))
expect(onSelect).toHaveBeenCalledWith('root1')
})
it('marks the selected node visually', () => {
render(<TreeView nodes={nodes} selectedId="root2" />)
const treeitem = screen.getByRole('treeitem', { name: /Root Two/i })
expect(treeitem.getAttribute('aria-selected')).toBe('true')
})
})
describe('keyboard navigation', () => {
it('moves focus down with ArrowDown', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
rootOne.focus()
await user.keyboard('{ArrowDown}')
expect(screen.getByRole('treeitem', { name: /Root Two/i })).toHaveFocus()
})
it('moves focus up with ArrowUp', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
const rootTwo = screen.getByRole('treeitem', { name: /Root Two/i })
rootTwo.focus()
await user.keyboard('{ArrowUp}')
expect(screen.getByRole('treeitem', { name: /Root One/i })).toHaveFocus()
})
it('expands a collapsed node with ArrowRight', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
rootOne.focus()
await user.keyboard('{ArrowRight}')
expect(screen.getByText('Child One')).toBeInTheDocument()
})
it('moves to first child with ArrowRight when already expanded', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
rootOne.focus()
// First ArrowRight expands, second moves to first child
await user.keyboard('{ArrowRight}')
await user.keyboard('{ArrowRight}')
expect(screen.getByRole('treeitem', { name: /Child One/i })).toHaveFocus()
})
it('collapses an expanded node with ArrowLeft', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
rootOne.focus()
await user.keyboard('{ArrowRight}')
expect(screen.getByText('Child One')).toBeInTheDocument()
await user.keyboard('{ArrowLeft}')
expect(screen.queryByText('Child One')).not.toBeInTheDocument()
})
it('moves to parent with ArrowLeft on a child node', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
rootOne.focus()
// Expand root1 then move to child
await user.keyboard('{ArrowRight}')
await user.keyboard('{ArrowRight}')
// Now at Child One
expect(screen.getByRole('treeitem', { name: /Child One/i })).toHaveFocus()
await user.keyboard('{ArrowLeft}')
// Should move back to Root One
expect(rootOne).toHaveFocus()
})
it('selects focused node with Enter', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
render(<TreeView nodes={nodes} onSelect={onSelect} />)
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
rootOne.focus()
await user.keyboard('{Enter}')
expect(onSelect).toHaveBeenCalledWith('root1')
})
it('moves to first visible node with Home', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
const rootTwo = screen.getByRole('treeitem', { name: /Root Two/i })
rootTwo.focus()
await user.keyboard('{Home}')
expect(screen.getByRole('treeitem', { name: /Root One/i })).toHaveFocus()
})
it('moves to last visible node with End', async () => {
const user = userEvent.setup()
render(<TreeView nodes={nodes} />)
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
rootOne.focus()
await user.keyboard('{End}')
expect(screen.getByRole('treeitem', { name: /Root Two/i })).toHaveFocus()
})
})
describe('deep nesting', () => {
it('renders grandchildren when both ancestor nodes are expanded', () => {
render(
<TreeView
nodes={nodes}
expandedIds={['root1', 'child1']}
onToggle={() => {}}
/>,
)
expect(screen.getByText('Grandchild One')).toBeInTheDocument()
expect(screen.getByText('Grandchild Two')).toBeInTheDocument()
})
it('does not render grandchildren when parent is collapsed', () => {
render(
<TreeView
nodes={nodes}
expandedIds={['root1']}
onToggle={() => {}}
/>,
)
expect(screen.queryByText('Grandchild One')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,289 @@
import { useState, useRef, useCallback, type ReactNode, type KeyboardEvent } from 'react'
import styles from './TreeView.module.css'
export interface TreeNode {
id: string
label: string
icon?: ReactNode
children?: TreeNode[]
meta?: string
}
interface FlatNode {
node: TreeNode
depth: number
parentId: string | null
}
function flattenVisibleNodes(
nodes: TreeNode[],
expandedIds: Set<string>,
depth = 0,
parentId: string | null = null,
): FlatNode[] {
const result: FlatNode[] = []
for (const node of nodes) {
result.push({ node, depth, parentId })
if (node.children && node.children.length > 0 && expandedIds.has(node.id)) {
result.push(...flattenVisibleNodes(node.children, expandedIds, depth + 1, node.id))
}
}
return result
}
interface TreeViewProps {
nodes: TreeNode[]
onSelect?: (id: string) => void
selectedId?: string
expandedIds?: string[]
onToggle?: (id: string) => void
className?: string
}
export function TreeView({
nodes,
onSelect,
selectedId,
expandedIds: controlledExpandedIds,
onToggle,
className,
}: TreeViewProps) {
// Controlled vs uncontrolled expansion
const isControlled = controlledExpandedIds !== undefined && onToggle !== undefined
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set())
const expandedSet = isControlled
? new Set(controlledExpandedIds)
: internalExpandedIds
function handleToggle(id: string) {
if (isControlled) {
onToggle!(id)
} else {
setInternalExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
}
// Keyboard navigation
const [focusedId, setFocusedId] = useState<string | null>(null)
const treeRef = useRef<HTMLUListElement>(null)
const visibleNodes = flattenVisibleNodes(nodes, expandedSet)
function getFocusedIndex() {
if (focusedId === null) return -1
return visibleNodes.findIndex((fn) => fn.node.id === focusedId)
}
function focusNode(id: string) {
// We focus the element directly. All tree items have tabIndex={-1} by default
// which means programmatic focus works even without tabIndex=0.
// The element's onFocus handler will fire and call setFocusedId — but that
// happens synchronously within the React event so it's properly batched.
const el = treeRef.current?.querySelector(`[data-nodeid="${id}"]`) as HTMLElement | null
if (el) {
// Temporarily make focusable if not already
el.focus()
} else {
// Element not in DOM yet (e.g. after expand); update state so it renders
// with tabIndex=0 and the browser's next focus movement will work.
setFocusedId(id)
}
}
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLUListElement>) => {
const currentIndex = getFocusedIndex()
const current = visibleNodes[currentIndex]
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
break
}
case 'ArrowRight': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren) {
if (!expandedSet.has(current.node.id)) {
// Expand it
handleToggle(current.node.id)
} else {
// Move to first child (it will be the next visible node)
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
break
}
case 'ArrowLeft': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
// Collapse
handleToggle(current.node.id)
} else if (current.parentId !== null) {
// Move to parent
focusNode(current.parentId)
}
break
}
case 'Enter': {
e.preventDefault()
if (current) {
onSelect?.(current.node.id)
}
break
}
case 'Home': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[0].node.id)
}
break
}
case 'End': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
}
break
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleNodes, expandedSet, focusedId],
)
return (
<ul
ref={treeRef}
role="tree"
className={`${styles.root} ${className ?? ''}`}
onKeyDown={handleKeyDown}
>
{nodes.map((node) => (
<TreeNodeRow
key={node.id}
node={node}
depth={0}
expandedSet={expandedSet}
selectedId={selectedId}
focusedId={focusedId}
onToggle={handleToggle}
onSelect={onSelect}
onFocus={setFocusedId}
/>
))}
</ul>
)
}
interface TreeNodeRowProps {
node: TreeNode
depth: number
expandedSet: Set<string>
selectedId?: string
focusedId: string | null
onToggle: (id: string) => void
onSelect?: (id: string) => void
onFocus: (id: string) => void
}
function TreeNodeRow({
node,
depth,
expandedSet,
selectedId,
focusedId,
onToggle,
onSelect,
onFocus,
}: TreeNodeRowProps) {
const hasChildren = node.children && node.children.length > 0
const isExpanded = expandedSet.has(node.id)
const isSelected = selectedId === node.id
const isFocused = focusedId === node.id
function handleClick() {
if (hasChildren) {
onToggle(node.id)
}
onSelect?.(node.id)
}
const rowClass = [
styles.row,
isSelected ? styles.selected : '',
]
.filter(Boolean)
.join(' ')
return (
<li role="none">
<div
role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
tabIndex={isFocused || (focusedId === null && depth === 0 && node === node) ? 0 : -1}
data-nodeid={node.id}
className={rowClass}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleClick}
onFocus={() => onFocus(node.id)}
>
<span className={styles.chevronSlot}>
{hasChildren ? (
<span className={styles.chevron} aria-hidden="true">
{isExpanded ? '▾' : '▸'}
</span>
) : null}
</span>
{node.icon && (
<span className={styles.icon} aria-hidden="true">
{node.icon}
</span>
)}
<span className={styles.label}>{node.label}</span>
{node.meta && (
<span className={styles.meta}>{node.meta}</span>
)}
</div>
{hasChildren && isExpanded && (
<ul role="group" className={styles.children}>
{node.children!.map((child) => (
<TreeNodeRow
key={child.id}
node={child}
depth={depth + 1}
expandedSet={expandedSet}
selectedId={selectedId}
focusedId={focusedId}
onToggle={onToggle}
onSelect={onSelect}
onFocus={onFocus}
/>
))}
</ul>
)}
</li>
)
}

View File

@@ -1,4 +1,7 @@
export { Accordion } from './Accordion/Accordion'
export { AlertDialog } from './AlertDialog/AlertDialog'
export { AreaChart } from './AreaChart/AreaChart' export { AreaChart } from './AreaChart/AreaChart'
export { AvatarGroup } from './AvatarGroup/AvatarGroup'
export { BarChart } from './BarChart/BarChart' export { BarChart } from './BarChart/BarChart'
export { Breadcrumb } from './Breadcrumb/Breadcrumb' export { Breadcrumb } from './Breadcrumb/Breadcrumb'
export { CommandPalette } from './CommandPalette/CommandPalette' export { CommandPalette } from './CommandPalette/CommandPalette'
@@ -13,7 +16,10 @@ export { FilterBar } from './FilterBar/FilterBar'
export { LineChart } from './LineChart/LineChart' export { LineChart } from './LineChart/LineChart'
export { MenuItem } from './MenuItem/MenuItem' export { MenuItem } from './MenuItem/MenuItem'
export { Modal } from './Modal/Modal' export { Modal } from './Modal/Modal'
export { Popover } from './Popover/Popover'
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline' export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline' export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar' export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { Tabs } from './Tabs/Tabs' export { Tabs } from './Tabs/Tabs'
export { ToastProvider, useToast } from './Toast/Toast'
export { TreeView } from './TreeView/TreeView'

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

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

View 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"
>
&times;
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,18 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 6px;
font-family: var(--font-body);
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.error {
font-size: 11px;
color: var(--error);
margin-top: 4px;
}

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { FormField } from './FormField'
describe('FormField', () => {
it('renders label and children', () => {
render(
<FormField label="Username" htmlFor="username">
<input id="username" type="text" />
</FormField>,
)
expect(screen.getByText('Username')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('renders hint text when no error', () => {
render(
<FormField label="Email" hint="We will never share your email" htmlFor="email">
<input id="email" type="email" />
</FormField>,
)
expect(screen.getByText('We will never share your email')).toBeInTheDocument()
})
it('renders error instead of hint when error is provided', () => {
render(
<FormField
label="Email"
hint="We will never share your email"
error="Invalid email address"
htmlFor="email"
>
<input id="email" type="email" />
</FormField>,
)
expect(screen.getByText('Invalid email address')).toBeInTheDocument()
expect(screen.queryByText('We will never share your email')).not.toBeInTheDocument()
})
it('shows required asterisk via Label when required is true', () => {
render(
<FormField label="Password" required htmlFor="password">
<input id="password" type="password" />
</FormField>,
)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('adds error class to wrapper when error is present', () => {
const { container } = render(
<FormField label="Name" error="Name is required" htmlFor="name">
<input id="name" type="text" />
</FormField>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toMatch(/error/)
})
it('does not add error class when no error', () => {
const { container } = render(
<FormField label="Name" htmlFor="name">
<input id="name" type="text" />
</FormField>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).not.toMatch(/error/)
})
it('renders children without label when label prop is omitted', () => {
render(
<FormField>
<input type="text" aria-label="standalone input" />
</FormField>,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.queryByRole('label')).not.toBeInTheDocument()
})
it('associates label with input via htmlFor', () => {
render(
<FormField label="Search" htmlFor="search-input">
<input id="search-input" type="text" />
</FormField>,
)
const label = screen.getByText('Search').closest('label')
expect(label).toHaveAttribute('for', 'search-input')
})
})

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react'
import { Label } from '../Label/Label'
import styles from './FormField.module.css'
interface FormFieldProps {
label?: string
htmlFor?: string
required?: boolean
error?: string
hint?: string
children: ReactNode
className?: string
}
export function FormField({
label,
htmlFor,
required,
error,
hint,
children,
className,
}: FormFieldProps) {
const wrapperClass = [styles.wrapper, error ? styles.error : '', className ?? '']
.filter(Boolean)
.join(' ')
return (
<div className={wrapperClass}>
{label && (
<Label htmlFor={htmlFor} required={required}>
{label}
</Label>
)}
{children}
{error ? (
<span className={styles.error} role="alert">
{error}
</span>
) : hint ? (
<span className={styles.hint}>{hint}</span>
) : null}
</div>
)
}
FormField.displayName = 'FormField'

View File

@@ -0,0 +1,11 @@
.label {
font-family: var(--font-body);
font-size: 12px;
color: var(--text-primary);
font-weight: 500;
}
.asterisk {
color: var(--error);
margin-left: 2px;
}

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Label } from './Label'
describe('Label', () => {
it('renders label text', () => {
render(<Label>Email address</Label>)
expect(screen.getByText('Email address')).toBeInTheDocument()
})
it('does not show asterisk when required is not set', () => {
render(<Label>Username</Label>)
expect(screen.queryByText('*')).not.toBeInTheDocument()
})
it('shows asterisk when required', () => {
render(<Label required>Password</Label>)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('passes htmlFor to the label element', () => {
render(<Label htmlFor="email-input">Email</Label>)
const label = screen.getByText('Email')
expect(label).toHaveAttribute('for', 'email-input')
})
it('forwards ref to the label element', () => {
let ref: HTMLLabelElement | null = null
render(
<Label ref={(el) => { ref = el }}>Ref test</Label>
)
expect(ref).toBeInstanceOf(HTMLLabelElement)
})
})

View File

@@ -0,0 +1,20 @@
import styles from './Label.module.css'
import { forwardRef, type LabelHTMLAttributes, type ReactNode } from 'react'
interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
required?: boolean
children?: ReactNode
className?: string
}
export const Label = forwardRef<HTMLLabelElement, LabelProps>(
({ required, children, className, ...rest }, ref) => {
return (
<label ref={ref} className={`${styles.label} ${className ?? ''}`} {...rest}>
{children}
{required && <span className={styles.asterisk}>*</span>}
</label>
)
},
)
Label.displayName = 'Label'

View File

@@ -0,0 +1,79 @@
.pagination {
display: inline-flex;
align-items: center;
gap: 2px;
}
/* Prev / Next nav buttons — ghost style */
.navBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.navBtn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.navBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Individual page number buttons */
.pageBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.pageBtn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.pageBtn:disabled:not(.active) {
opacity: 0.4;
cursor: not-allowed;
}
/* Active page */
.active {
background: var(--amber);
color: white;
cursor: default;
}
/* Ellipsis */
.ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
user-select: none;
}

View File

@@ -0,0 +1,141 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Pagination, getPageRange } from './Pagination'
describe('getPageRange', () => {
it('returns all pages when totalPages is small', () => {
expect(getPageRange(1, 5, 1)).toEqual([1, 2, 3, 4, 5])
})
it('returns only page 1 when totalPages is 1', () => {
expect(getPageRange(1, 1, 1)).toEqual([1])
})
it('adds left ellipsis when current page is far from start', () => {
const range = getPageRange(10, 20, 1)
expect(range[0]).toBe(1)
expect(range[1]).toBe('ellipsis')
})
it('adds right ellipsis when current page is far from end', () => {
const range = getPageRange(1, 20, 1)
const lastEllipsisIdx = range.lastIndexOf('ellipsis')
expect(lastEllipsisIdx).toBeGreaterThan(-1)
expect(range[range.length - 1]).toBe(20)
})
it('shows first and last page always', () => {
const range = getPageRange(5, 20, 1)
expect(range[0]).toBe(1)
expect(range[range.length - 1]).toBe(20)
})
it('renders pages around current with siblingCount=1', () => {
// page 5, totalPages 20, siblingCount 1 → 1 ... 4 5 6 ... 20
const range = getPageRange(5, 20, 1)
expect(range).toContain(4)
expect(range).toContain(5)
expect(range).toContain(6)
})
it('renders wider sibling window with siblingCount=2', () => {
// page 10, totalPages 20, siblingCount 2 → 1 ... 8 9 10 11 12 ... 20
const range = getPageRange(10, 20, 2)
expect(range).toContain(8)
expect(range).toContain(9)
expect(range).toContain(10)
expect(range).toContain(11)
expect(range).toContain(12)
})
it('does not duplicate page 1 when siblings reach the start', () => {
const range = getPageRange(2, 20, 1)
const ones = range.filter(x => x === 1)
expect(ones).toHaveLength(1)
})
it('does not duplicate last page when siblings reach the end', () => {
const range = getPageRange(19, 20, 1)
const twenties = range.filter(x => x === 20)
expect(twenties).toHaveLength(1)
})
})
describe('Pagination', () => {
it('renders prev and next buttons', () => {
render(<Pagination page={5} totalPages={20} onPageChange={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Previous page' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next page' })).toBeInTheDocument()
})
it('marks the current page with aria-current', () => {
render(<Pagination page={5} totalPages={20} onPageChange={vi.fn()} />)
const activeBtn = screen.getByRole('button', { name: 'Page 5' })
expect(activeBtn).toHaveAttribute('aria-current', 'page')
})
it('shows ellipsis when pages are far apart', () => {
render(<Pagination page={10} totalPages={20} onPageChange={vi.fn()} />)
const ellipses = screen.getAllByText('…')
expect(ellipses.length).toBeGreaterThanOrEqual(1)
})
it('disables prev button on first page', () => {
render(<Pagination page={1} totalPages={10} onPageChange={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
})
it('does not disable next button on first page', () => {
render(<Pagination page={1} totalPages={10} onPageChange={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Next page' })).not.toBeDisabled()
})
it('disables next button on last page', () => {
render(<Pagination page={10} totalPages={10} onPageChange={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
})
it('does not disable prev button on last page', () => {
render(<Pagination page={10} totalPages={10} onPageChange={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Previous page' })).not.toBeDisabled()
})
it('fires onPageChange with page - 1 when prev is clicked', async () => {
const user = userEvent.setup()
const onPageChange = vi.fn()
render(<Pagination page={5} totalPages={20} onPageChange={onPageChange} />)
await user.click(screen.getByRole('button', { name: 'Previous page' }))
expect(onPageChange).toHaveBeenCalledWith(4)
})
it('fires onPageChange with page + 1 when next is clicked', async () => {
const user = userEvent.setup()
const onPageChange = vi.fn()
render(<Pagination page={5} totalPages={20} onPageChange={onPageChange} />)
await user.click(screen.getByRole('button', { name: 'Next page' }))
expect(onPageChange).toHaveBeenCalledWith(6)
})
it('fires onPageChange with the clicked page number', async () => {
const user = userEvent.setup()
const onPageChange = vi.fn()
render(<Pagination page={5} totalPages={20} onPageChange={onPageChange} />)
await user.click(screen.getByRole('button', { name: 'Page 6' }))
expect(onPageChange).toHaveBeenCalledWith(6)
})
it('applies custom className', () => {
const { container } = render(
<Pagination page={1} totalPages={5} onPageChange={vi.fn()} className="custom-class" />
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('renders with siblingCount=0 showing only current page between first and last', () => {
render(<Pagination page={10} totalPages={20} onPageChange={vi.fn()} siblingCount={0} />)
expect(screen.getByRole('button', { name: 'Page 10' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Page 1' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Page 20' })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,113 @@
import styles from './Pagination.module.css'
import type { HTMLAttributes } from 'react'
interface PaginationProps extends HTMLAttributes<HTMLElement> {
page: number
totalPages: number
onPageChange: (page: number) => void
siblingCount?: number
className?: string
}
export function getPageRange(
page: number,
totalPages: number,
siblingCount: number
): (number | 'ellipsis')[] {
if (totalPages <= 1) return [1]
// If total pages fit in a compact window, just return all pages with no ellipsis
const totalPageNumbers = siblingCount * 2 + 5 // 1(first) + 1(last) + 1(current) + 2*siblings + 2(ellipsis slots)
if (totalPages <= totalPageNumbers) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
const range: (number | 'ellipsis')[] = []
const left = Math.max(2, page - siblingCount)
const right = Math.min(totalPages - 1, page + siblingCount)
// First page always shown
range.push(1)
// Left ellipsis: gap between 1 and left boundary hides at least 1 page
if (left > 2) {
range.push('ellipsis')
}
// Middle pages
for (let i = left; i <= right; i++) {
range.push(i)
}
// Right ellipsis: gap between right boundary and last page hides at least 1 page
if (right < totalPages - 1) {
range.push('ellipsis')
}
// Last page always shown
range.push(totalPages)
return range
}
export function Pagination({
page,
totalPages,
onPageChange,
siblingCount = 1,
className,
...rest
}: PaginationProps) {
const pageRange = getPageRange(page, totalPages, siblingCount)
return (
<nav
aria-label="Pagination"
className={`${styles.pagination} ${className ?? ''}`}
{...rest}
>
<button
className={styles.navBtn}
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
aria-label="Previous page"
>
&lt;
</button>
{pageRange.map((item, idx) => {
if (item === 'ellipsis') {
return (
<span key={`ellipsis-${idx}`} className={styles.ellipsis} aria-hidden="true">
&hellip;
</span>
)
}
const isActive = item === page
return (
<button
key={item}
className={`${styles.pageBtn} ${isActive ? styles.active : ''}`}
onClick={() => onPageChange(item)}
aria-current={isActive ? 'page' : undefined}
aria-label={`Page ${item}`}
disabled={isActive}
>
{item}
</button>
)
})}
<button
className={styles.navBtn}
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
aria-label="Next page"
>
&gt;
</button>
</nav>
)
}

View File

@@ -0,0 +1,47 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.label {
font-family: var(--font-body);
font-size: 11px;
color: var(--text-secondary);
line-height: 1;
}
.track {
width: 100%;
background: var(--bg-inset);
border-radius: 4px;
overflow: hidden;
}
.sm { height: 4px; }
.md { height: 8px; }
.fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.primary { background: var(--amber); }
.success { background: var(--success); }
.warning { background: var(--warning); }
.error { background: var(--error); }
.running { background: var(--running); }
/* Indeterminate shimmer */
@keyframes shimmer {
0% { transform: translateX(-100%); width: 40%; }
50% { transform: translateX(75%); width: 60%; }
100% { transform: translateX(250%); width: 40%; }
}
.indeterminate {
width: 40% !important;
animation: shimmer 1.4s ease-in-out infinite;
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ProgressBar } from './ProgressBar'
describe('ProgressBar', () => {
it('renders with default props', () => {
render(<ProgressBar />)
const bar = screen.getByRole('progressbar')
expect(bar).toBeInTheDocument()
})
it('sets aria-valuenow to the clamped value', () => {
render(<ProgressBar value={42} />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveAttribute('aria-valuenow', '42')
})
it('always sets aria-valuemin=0 and aria-valuemax=100', () => {
render(<ProgressBar value={50} />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveAttribute('aria-valuemin', '0')
expect(bar).toHaveAttribute('aria-valuemax', '100')
})
it('clamps value above 100 to 100', () => {
render(<ProgressBar value={150} />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveAttribute('aria-valuenow', '100')
})
it('clamps value below 0 to 0', () => {
render(<ProgressBar value={-10} />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveAttribute('aria-valuenow', '0')
})
it('omits aria-valuenow when indeterminate', () => {
render(<ProgressBar indeterminate />)
const bar = screen.getByRole('progressbar')
expect(bar).not.toHaveAttribute('aria-valuenow')
})
it('renders label text above the bar', () => {
render(<ProgressBar label="Upload progress" value={60} />)
expect(screen.getByText('Upload progress')).toBeInTheDocument()
})
it('does not render label element when label is omitted', () => {
render(<ProgressBar value={60} />)
expect(screen.queryByText('Upload progress')).not.toBeInTheDocument()
})
it('applies variant class to fill', () => {
const { container } = render(<ProgressBar variant="success" value={50} />)
const fill = container.querySelector('.fill')
expect(fill).toHaveClass('success')
})
it('applies error variant class to fill', () => {
const { container } = render(<ProgressBar variant="error" value={50} />)
const fill = container.querySelector('.fill')
expect(fill).toHaveClass('error')
})
it('applies sm size class to track', () => {
render(<ProgressBar size="sm" value={50} />)
const track = screen.getByRole('progressbar')
expect(track).toHaveClass('sm')
})
it('applies md size class to track by default', () => {
render(<ProgressBar value={50} />)
const track = screen.getByRole('progressbar')
expect(track).toHaveClass('md')
})
it('applies indeterminate class to fill when indeterminate', () => {
const { container } = render(<ProgressBar indeterminate />)
const fill = container.querySelector('.fill')
expect(fill).toHaveClass('indeterminate')
})
it('sets fill width style matching value', () => {
const { container } = render(<ProgressBar value={75} />)
const fill = container.querySelector('.fill') as HTMLElement
expect(fill.style.width).toBe('75%')
})
it('does not set width style when indeterminate', () => {
const { container } = render(<ProgressBar indeterminate />)
const fill = container.querySelector('.fill') as HTMLElement
expect(fill.style.width).toBe('')
})
it('passes through className to the track', () => {
render(<ProgressBar className="custom-class" value={30} />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,60 @@
import styles from './ProgressBar.module.css'
type ProgressBarVariant = 'primary' | 'success' | 'warning' | 'error' | 'running'
type ProgressBarSize = 'sm' | 'md'
interface ProgressBarProps {
value?: number
variant?: ProgressBarVariant
size?: ProgressBarSize
indeterminate?: boolean
label?: string
className?: string
}
export function ProgressBar({
value = 0,
variant = 'primary',
size = 'md',
indeterminate = false,
label,
className,
}: ProgressBarProps) {
const clampedValue = Math.min(100, Math.max(0, value))
const trackClasses = [
styles.track,
styles[size],
className ?? '',
].filter(Boolean).join(' ')
const fillClasses = [
styles.fill,
styles[variant],
indeterminate ? styles.indeterminate : '',
].filter(Boolean).join(' ')
const ariaProps = indeterminate
? { 'aria-valuenow': undefined }
: { 'aria-valuenow': clampedValue }
return (
<div className={styles.wrapper}>
{label && (
<span className={styles.label}>{label}</span>
)}
<div
className={trackClasses}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
{...ariaProps}
>
<div
className={fillClasses}
style={indeterminate ? undefined : { width: `${clampedValue}%` }}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,92 @@
/* ── RadioGroup layout ────────────────────────────────────────────────────── */
.group {
display: flex;
}
.vertical {
flex-direction: column;
gap: 8px;
}
.horizontal {
flex-direction: row;
gap: 16px;
}
/* ── RadioItem wrapper ────────────────────────────────────────────────────── */
.wrapper {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.wrapperDisabled {
cursor: not-allowed;
opacity: 0.6;
}
/* ── Hidden native input ──────────────────────────────────────────────────── */
.input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
margin: 0;
}
/* ── Custom circle ────────────────────────────────────────────────────────── */
.circle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-raised);
flex-shrink: 0;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
/* Inner dot when checked */
.circle::after {
content: '';
display: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--amber);
}
.input:checked + .circle {
border-color: var(--amber);
background: var(--bg-raised);
}
.input:checked + .circle::after {
display: block;
}
.input:focus-visible + .circle {
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg);
}
.input:disabled + .circle {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Label ────────────────────────────────────────────────────────────────── */
.label {
font-family: var(--font-body);
font-size: 12px;
color: var(--text-primary);
}

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RadioGroup, RadioItem } from './Radio'
describe('RadioGroup + RadioItem', () => {
it('renders all options with correct labels', () => {
render(
<RadioGroup name="color" value="red" onChange={vi.fn()}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" />
<RadioItem value="green" label="Green" />
</RadioGroup>,
)
expect(screen.getByLabelText('Red')).toBeInTheDocument()
expect(screen.getByLabelText('Blue')).toBeInTheDocument()
expect(screen.getByLabelText('Green')).toBeInTheDocument()
})
it('marks the current value as checked', () => {
render(
<RadioGroup name="color" value="blue" onChange={vi.fn()}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" />
</RadioGroup>,
)
expect(screen.getByLabelText('Red')).not.toBeChecked()
expect(screen.getByLabelText('Blue')).toBeChecked()
})
it('calls onChange with the selected value when clicking an option', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
<RadioGroup name="color" value="red" onChange={onChange}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" />
</RadioGroup>,
)
await user.click(screen.getByLabelText('Blue'))
expect(onChange).toHaveBeenCalledWith('blue')
})
it('does not call onChange when clicking a disabled item', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
<RadioGroup name="color" value="red" onChange={onChange}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" disabled />
</RadioGroup>,
)
await user.click(screen.getByLabelText('Blue'))
expect(onChange).not.toHaveBeenCalled()
})
it('disabled item has disabled attribute', () => {
render(
<RadioGroup name="color" value="red" onChange={vi.fn()}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" disabled />
</RadioGroup>,
)
expect(screen.getByLabelText('Blue')).toBeDisabled()
expect(screen.getByLabelText('Red')).not.toBeDisabled()
})
it('all inputs share the same name', () => {
render(
<RadioGroup name="size" value="md" onChange={vi.fn()}>
<RadioItem value="sm" label="Small" />
<RadioItem value="md" label="Medium" />
<RadioItem value="lg" label="Large" />
</RadioGroup>,
)
const inputs = screen.getAllByRole('radio')
inputs.forEach((input) => {
expect(input).toHaveAttribute('name', 'size')
})
})
it('supports keyboard navigation between items', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
<RadioGroup name="color" value="red" onChange={onChange}>
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" />
</RadioGroup>,
)
const redInput = screen.getByLabelText('Red')
redInput.focus()
await user.keyboard('{ArrowDown}')
expect(onChange).toHaveBeenCalledWith('blue')
})
it('applies horizontal layout class when orientation is horizontal', () => {
const { container } = render(
<RadioGroup name="color" value="red" onChange={vi.fn()} orientation="horizontal">
<RadioItem value="red" label="Red" />
<RadioItem value="blue" label="Blue" />
</RadioGroup>,
)
const group = container.firstChild as HTMLElement
expect(group.className).toMatch(/horizontal/)
})
it('applies vertical layout class by default', () => {
const { container } = render(
<RadioGroup name="color" value="red" onChange={vi.fn()}>
<RadioItem value="red" label="Red" />
</RadioGroup>,
)
const group = container.firstChild as HTMLElement
expect(group.className).toMatch(/vertical/)
})
it('accepts a ReactNode as label', () => {
render(
<RadioGroup name="color" value="red" onChange={vi.fn()}>
<RadioItem value="red" label={<strong>Bold Red</strong>} />
</RadioGroup>,
)
expect(screen.getByText('Bold Red')).toBeInTheDocument()
})
it('applies custom className to RadioGroup', () => {
const { container } = render(
<RadioGroup name="color" value="red" onChange={vi.fn()} className="custom-class">
<RadioItem value="red" label="Red" />
</RadioGroup>,
)
const group = container.firstChild as HTMLElement
expect(group.className).toContain('custom-class')
})
})

View File

@@ -0,0 +1,128 @@
import styles from './Radio.module.css'
import { createContext, useContext, type ReactNode } from 'react'
interface RadioGroupContextValue {
name: string
value: string
onChange: (value: string) => void
}
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
function useRadioGroup(): RadioGroupContextValue {
const ctx = useContext(RadioGroupContext)
if (!ctx) {
throw new Error('RadioItem must be used inside a RadioGroup')
}
return ctx
}
// ── RadioGroup ────────────────────────────────────────────────────────────────
interface RadioGroupProps {
name: string
value: string
onChange: (value: string) => void
orientation?: 'vertical' | 'horizontal'
children?: ReactNode
className?: string
}
export function RadioGroup({
name,
value,
onChange,
orientation = 'vertical',
children,
className,
}: RadioGroupProps) {
return (
<RadioGroupContext.Provider value={{ name, value, onChange }}>
<div
role="radiogroup"
className={`${styles.group} ${styles[orientation]} ${className ?? ''}`}
>
{children}
</div>
</RadioGroupContext.Provider>
)
}
// ── RadioItem ─────────────────────────────────────────────────────────────────
interface RadioItemProps {
value: string
label: ReactNode
disabled?: boolean
}
export function RadioItem({ value, label, disabled = false }: RadioItemProps) {
const ctx = useRadioGroup()
const inputId = `radio-${ctx.name}-${value}`
const isChecked = ctx.value === value
function handleChange() {
if (!disabled) {
ctx.onChange(value)
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
// Native radio keyboard behaviour fires onChange on ArrowDown/Up/Left/Right
// but since we control the value externally we need to relay those events.
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
e.preventDefault()
const inputs = getGroupInputs(e.currentTarget)
const next = getNextEnabled(inputs, e.currentTarget, 1)
if (next) {
ctx.onChange(next.value)
next.focus()
}
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault()
const inputs = getGroupInputs(e.currentTarget)
const prev = getNextEnabled(inputs, e.currentTarget, -1)
if (prev) {
ctx.onChange(prev.value)
prev.focus()
}
}
}
return (
<label className={`${styles.wrapper} ${disabled ? styles.wrapperDisabled : ''}`} htmlFor={inputId}>
<input
id={inputId}
type="radio"
name={ctx.name}
value={value}
checked={isChecked}
disabled={disabled}
className={styles.input}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
<span className={styles.circle} aria-hidden="true" />
{label && <span className={styles.label}>{label}</span>}
</label>
)
}
// ── helpers ───────────────────────────────────────────────────────────────────
function getGroupInputs(current: HTMLInputElement): HTMLInputElement[] {
const group = current.closest('[role="radiogroup"]')
if (!group) return []
return Array.from(group.querySelectorAll<HTMLInputElement>('input[type="radio"]:not(:disabled)'))
}
function getNextEnabled(
inputs: HTMLInputElement[],
current: HTMLInputElement,
direction: 1 | -1,
): HTMLInputElement | null {
const idx = inputs.indexOf(current)
if (idx === -1) return null
const next = (idx + direction + inputs.length) % inputs.length
return inputs[next] ?? null
}

View File

@@ -0,0 +1,48 @@
@keyframes shimmer {
from {
background-position: -200% center;
}
to {
background-position: 200% center;
}
}
.skeleton {
background-color: var(--bg-inset);
display: block;
}
.shimmer {
background-image: linear-gradient(
90deg,
var(--bg-inset) 25%,
color-mix(in srgb, var(--bg-inset) 60%, transparent) 50%,
var(--bg-inset) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
.text {
height: 12px;
border-radius: 4px;
width: 100%;
}
.textGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.circular {
border-radius: 50%;
width: 40px;
height: 40px;
}
.rectangular {
border-radius: var(--radius-sm);
width: 100%;
height: 80px;
}

View File

@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Skeleton } from './Skeleton'
describe('Skeleton', () => {
it('renders rectangular variant by default', () => {
const { container } = render(<Skeleton />)
const el = container.firstChild as HTMLElement
expect(el).toHaveClass('rectangular')
expect(el).toHaveClass('shimmer')
})
it('renders text variant with single bar', () => {
const { container } = render(<Skeleton variant="text" />)
const el = container.firstChild as HTMLElement
expect(el).toHaveClass('text')
expect(el).toHaveClass('shimmer')
})
it('renders circular variant', () => {
const { container } = render(<Skeleton variant="circular" />)
const el = container.firstChild as HTMLElement
expect(el).toHaveClass('circular')
expect(el).toHaveClass('shimmer')
})
it('renders multiple lines for text variant when lines > 1', () => {
const { container } = render(<Skeleton variant="text" lines={3} />)
const group = container.firstChild as HTMLElement
expect(group).toHaveClass('textGroup')
const bars = group.querySelectorAll('.text')
expect(bars).toHaveLength(3)
})
it('renders last bar at 70% width when lines > 1', () => {
const { container } = render(<Skeleton variant="text" lines={3} />)
const group = container.firstChild as HTMLElement
const bars = group.querySelectorAll('.text')
const lastBar = bars[bars.length - 1] as HTMLElement
expect(lastBar.style.width).toBe('70%')
})
it('applies custom width and height', () => {
const { container } = render(<Skeleton variant="rectangular" width={200} height={50} />)
const el = container.firstChild as HTMLElement
expect(el.style.width).toBe('200px')
expect(el.style.height).toBe('50px')
})
it('applies custom width and height as strings', () => {
const { container } = render(<Skeleton variant="circular" width="80px" height="80px" />)
const el = container.firstChild as HTMLElement
expect(el.style.width).toBe('80px')
expect(el.style.height).toBe('80px')
})
it('applies extra className', () => {
const { container } = render(<Skeleton className="custom-class" />)
const el = container.firstChild as HTMLElement
expect(el).toHaveClass('custom-class')
})
it('circular has default 40x40 dimensions', () => {
const { container } = render(<Skeleton variant="circular" />)
const el = container.firstChild as HTMLElement
expect(el.style.width).toBe('40px')
expect(el.style.height).toBe('40px')
})
it('rectangular has default 80px height and 100% width', () => {
const { container } = render(<Skeleton variant="rectangular" />)
const el = container.firstChild as HTMLElement
expect(el.style.width).toBe('100%')
expect(el.style.height).toBe('80px')
})
it('is aria-hidden', () => {
const { container } = render(<Skeleton />)
const el = container.firstChild as HTMLElement
expect(el).toHaveAttribute('aria-hidden', 'true')
})
})

View File

@@ -0,0 +1,71 @@
import styles from './Skeleton.module.css'
interface SkeletonProps {
variant?: 'text' | 'circular' | 'rectangular'
width?: string | number
height?: string | number
lines?: number
className?: string
}
function normalizeSize(value: string | number | undefined): string | undefined {
if (value === undefined) return undefined
return typeof value === 'number' ? `${value}px` : value
}
export function Skeleton({
variant = 'rectangular',
width,
height,
lines,
className,
}: SkeletonProps) {
const w = normalizeSize(width)
const h = normalizeSize(height)
if (variant === 'text') {
const lineCount = lines && lines > 1 ? lines : 1
if (lineCount === 1) {
return (
<div
className={[styles.skeleton, styles.text, styles.shimmer, className ?? ''].filter(Boolean).join(' ')}
style={{ width: w, height: h }}
aria-hidden="true"
/>
)
}
return (
<div className={[styles.textGroup, className ?? ''].filter(Boolean).join(' ')} aria-hidden="true">
{Array.from({ length: lineCount }, (_, i) => (
<div
key={i}
className={[styles.skeleton, styles.text, styles.shimmer].join(' ')}
style={{
width: i === lineCount - 1 ? '70%' : (w ?? '100%'),
height: h,
}}
/>
))}
</div>
)
}
if (variant === 'circular') {
return (
<div
className={[styles.skeleton, styles.circular, styles.shimmer, className ?? ''].filter(Boolean).join(' ')}
style={{ width: w ?? '40px', height: h ?? '40px' }}
aria-hidden="true"
/>
)
}
// rectangular (default)
return (
<div
className={[styles.skeleton, styles.rectangular, styles.shimmer, className ?? ''].filter(Boolean).join(' ')}
style={{ width: w ?? '100%', height: h ?? '80px' }}
aria-hidden="true"
/>
)
}

View File

@@ -0,0 +1,21 @@
.textarea {
width: 100%;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
resize: vertical;
}
.textarea::placeholder { color: var(--text-faint); }
.textarea:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-bg); }
.resizeVertical { resize: vertical; }
.resizeHorizontal { resize: horizontal; }
.resizeNone { resize: none; }
.resizeBoth { resize: both; }

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createRef } from 'react'
import { Textarea } from './Textarea'
describe('Textarea', () => {
it('renders a textarea element', () => {
render(<Textarea placeholder="Enter text" />)
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument()
})
it('renders with default rows of 3', () => {
render(<Textarea />)
const el = screen.getByRole('textbox')
expect(el).toHaveAttribute('rows', '3')
})
it('accepts a custom rows value', () => {
render(<Textarea rows={5} />)
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '5')
})
it('accepts user input', async () => {
const user = userEvent.setup()
render(<Textarea />)
const el = screen.getByRole('textbox')
await user.type(el, 'hello world')
expect(el).toHaveValue('hello world')
})
it('calls onChange when value changes', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<Textarea onChange={onChange} />)
await user.type(screen.getByRole('textbox'), 'a')
expect(onChange).toHaveBeenCalled()
})
it('applies resize class for vertical (default)', () => {
render(<Textarea />)
const el = screen.getByRole('textbox')
expect(el.className).toContain('resizeVertical')
})
it('applies resize class for none', () => {
render(<Textarea resize="none" />)
const el = screen.getByRole('textbox')
expect(el.className).toContain('resizeNone')
})
it('applies resize class for horizontal', () => {
render(<Textarea resize="horizontal" />)
const el = screen.getByRole('textbox')
expect(el.className).toContain('resizeHorizontal')
})
it('applies resize class for both', () => {
render(<Textarea resize="both" />)
const el = screen.getByRole('textbox')
expect(el.className).toContain('resizeBoth')
})
it('forwards ref to the textarea element', () => {
const ref = createRef<HTMLTextAreaElement>()
render(<Textarea ref={ref} />)
expect(ref.current).toBeInstanceOf(HTMLTextAreaElement)
})
it('passes additional props to the textarea', () => {
render(<Textarea disabled data-testid="ta" />)
expect(screen.getByTestId('ta')).toBeDisabled()
})
it('applies custom className', () => {
render(<Textarea className="custom" />)
expect(screen.getByRole('textbox').className).toContain('custom')
})
})

View File

@@ -0,0 +1,31 @@
import styles from './Textarea.module.css'
import { forwardRef, type TextareaHTMLAttributes } from 'react'
type ResizeProp = 'vertical' | 'horizontal' | 'none' | 'both'
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
resize?: ResizeProp
}
const resizeClassMap: Record<ResizeProp, string> = {
vertical: styles.resizeVertical,
horizontal: styles.resizeHorizontal,
none: styles.resizeNone,
both: styles.resizeBoth,
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ resize = 'vertical', className, rows = 3, ...rest }, ref) => {
const resizeClass = resizeClassMap[resize]
return (
<textarea
ref={ref}
rows={rows}
className={`${styles.textarea} ${resizeClass} ${className ?? ''}`}
{...rest}
/>
)
},
)
Textarea.displayName = 'Textarea'

View File

@@ -1,3 +1,4 @@
export { Alert } from './Alert/Alert'
export { Avatar } from './Avatar/Avatar' export { Avatar } from './Avatar/Avatar'
export { Badge } from './Badge/Badge' export { Badge } from './Badge/Badge'
export { Button } from './Button/Button' export { Button } from './Button/Button'
@@ -9,16 +10,23 @@ export { DateRangePicker } from './DateRangePicker/DateRangePicker'
export { DateTimePicker } from './DateTimePicker/DateTimePicker' export { DateTimePicker } from './DateTimePicker/DateTimePicker'
export { EmptyState } from './EmptyState/EmptyState' export { EmptyState } from './EmptyState/EmptyState'
export { FilterPill } from './FilterPill/FilterPill' export { FilterPill } from './FilterPill/FilterPill'
export { FormField } from './FormField/FormField'
export { InfoCallout } from './InfoCallout/InfoCallout' export { InfoCallout } from './InfoCallout/InfoCallout'
export { Input } from './Input/Input' export { Input } from './Input/Input'
export { KeyboardHint } from './KeyboardHint/KeyboardHint' export { KeyboardHint } from './KeyboardHint/KeyboardHint'
export { Label } from './Label/Label'
export { MonoText } from './MonoText/MonoText' export { MonoText } from './MonoText/MonoText'
export { Pagination } from './Pagination/Pagination'
export { ProgressBar } from './ProgressBar/ProgressBar'
export { RadioGroup, RadioItem } from './Radio/Radio'
export { SectionHeader } from './SectionHeader/SectionHeader' export { SectionHeader } from './SectionHeader/SectionHeader'
export { Select } from './Select/Select' export { Select } from './Select/Select'
export { Skeleton } from './Skeleton/Skeleton'
export { Sparkline } from './Sparkline/Sparkline' export { Sparkline } from './Sparkline/Sparkline'
export { Spinner } from './Spinner/Spinner' export { Spinner } from './Spinner/Spinner'
export { StatCard } from './StatCard/StatCard' export { StatCard } from './StatCard/StatCard'
export { StatusDot } from './StatusDot/StatusDot' export { StatusDot } from './StatusDot/StatusDot'
export { Tag } from './Tag/Tag' export { Tag } from './Tag/Tag'
export { Textarea } from './Textarea/Textarea'
export { Toggle } from './Toggle/Toggle' export { Toggle } from './Toggle/Toggle'
export { Tooltip } from './Tooltip/Tooltip' export { Tooltip } from './Tooltip/Tooltip'

View File

@@ -0,0 +1,87 @@
.page {
min-height: 100vh;
background: var(--bg-body);
font-family: var(--font-body);
}
.header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
gap: 16px;
}
.headerTitle {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.backLink {
font-size: 13px;
color: var(--amber);
text-decoration: none;
margin-left: auto;
}
.backLink:hover {
text-decoration: underline;
}
.body {
display: flex;
gap: 0;
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.nav {
width: 200px;
flex-shrink: 0;
position: sticky;
top: 57px;
height: calc(100vh - 57px);
overflow-y: auto;
padding-right: 16px;
}
.navSection {
margin-bottom: 8px;
}
.navLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
padding: 8px 8px 4px;
display: block;
}
.navLink {
display: block;
font-size: 13px;
color: var(--text-secondary);
text-decoration: none;
padding: 4px 8px;
border-radius: var(--radius-sm);
line-height: 1.5;
}
.navLink:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.content {
flex: 1;
min-width: 0;
}

View File

@@ -0,0 +1,41 @@
import { Link } from 'react-router-dom'
import styles from './Inventory.module.css'
import { PrimitivesSection } from './sections/PrimitivesSection'
import { CompositesSection } from './sections/CompositesSection'
import { LayoutSection } from './sections/LayoutSection'
const NAV_ITEMS = [
{ label: 'Primitives', href: '#primitives' },
{ label: 'Composites', href: '#composites' },
{ label: 'Layout', href: '#layout' },
]
export function Inventory() {
return (
<div className={styles.page}>
<header className={styles.header}>
<h1 className={styles.headerTitle}>Component Inventory</h1>
<Link to="/" className={styles.backLink}> Back to app</Link>
</header>
<div className={styles.body}>
<nav className={styles.nav} aria-label="Component categories">
<div className={styles.navSection}>
<span className={styles.navLabel}>Categories</span>
{NAV_ITEMS.map((item) => (
<a key={item.href} href={item.href} className={styles.navLink}>
{item.label}
</a>
))}
</div>
</nav>
<main className={styles.content}>
<PrimitivesSection />
<CompositesSection />
<LayoutSection />
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,74 @@
.section {
margin-bottom: 40px;
}
.sectionTitle {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 24px;
}
.componentCard {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
}
.componentTitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
display: flex;
align-items: center;
gap: 8px;
}
.componentDesc {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 16px;
}
.demoArea {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
}
.demoAreaColumn {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.demoAreaRow {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.demoLabel {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
}
.demoGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.demoGroupRow {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,598 @@
import { useState } from 'react'
import styles from './CompositesSection.module.css'
import {
Accordion,
AlertDialog,
AreaChart,
AvatarGroup,
BarChart,
Breadcrumb,
CommandPalette,
DataTable,
DetailPanel,
Dropdown,
EventFeed,
FilterBar,
LineChart,
MenuItem,
Modal,
Popover,
ProcessorTimeline,
ShortcutsBar,
Tabs,
ToastProvider,
useToast,
TreeView,
} from '../../../design-system/composites'
import type { SearchResult } from '../../../design-system/composites'
import { Button } from '../../../design-system/primitives'
// ── DemoCard helper ──────────────────────────────────────────────────────────
interface DemoCardProps {
id: string
title: string
description: string
children: React.ReactNode
}
function DemoCard({ id, title, description, children }: DemoCardProps) {
return (
<div id={id} className={styles.componentCard}>
<h3 className={styles.componentTitle}>{title}</h3>
<p className={styles.componentDesc}>{description}</p>
<div className={styles.demoArea}>{children}</div>
</div>
)
}
// ── Toast demo inner component (must be inside ToastProvider) ─────────────────
function ToastDemo() {
const { toast } = useToast()
return (
<div className={styles.demoAreaRow}>
<Button size="sm" variant="primary" onClick={() => toast({ title: 'Success!', description: 'Operation completed.', variant: 'success' })}>
Success toast
</Button>
<Button size="sm" variant="secondary" onClick={() => toast({ title: 'Info', description: 'Something to note.', variant: 'info' })}>
Info toast
</Button>
<Button size="sm" variant="danger" onClick={() => toast({ title: 'Error', description: 'Something went wrong.', variant: 'error' })}>
Error toast
</Button>
<Button size="sm" variant="ghost" onClick={() => toast({ title: 'Warning', description: 'Proceed with caution.', variant: 'warning' })}>
Warning toast
</Button>
</div>
)
}
// ── Sample data ───────────────────────────────────────────────────────────────
const CHART_SERIES = [
{
label: 'Requests',
data: [
{ x: 0, y: 120 }, { x: 1, y: 180 }, { x: 2, y: 150 },
{ x: 3, y: 210 }, { x: 4, y: 190 }, { x: 5, y: 240 },
],
},
{
label: 'Errors',
data: [
{ x: 0, y: 5 }, { x: 1, y: 12 }, { x: 2, y: 8 },
{ x: 3, y: 15 }, { x: 4, y: 7 }, { x: 5, y: 10 },
],
color: 'var(--error)',
},
]
const BAR_SERIES = [
{
label: 'GET',
data: [
{ x: 'Mon', y: 80 }, { x: 'Tue', y: 95 }, { x: 'Wed', y: 110 },
{ x: 'Thu', y: 72 }, { x: 'Fri', y: 130 },
],
},
{
label: 'POST',
data: [
{ x: 'Mon', y: 40 }, { x: 'Tue', y: 55 }, { x: 'Wed', y: 60 },
{ x: 'Thu', y: 38 }, { x: 'Fri', y: 75 },
],
color: 'var(--success)',
},
]
const COMMAND_PALETTE_DATA: SearchResult[] = [
{ id: 'r1', category: 'route', title: 'order-ingest', meta: 'POST /orders/ingest' },
{ id: 'r2', category: 'route', title: 'payment-validate', meta: 'POST /payments/validate' },
{ id: 'e1', category: 'execution', title: 'exec-001', meta: 'Started 2m ago' },
{ id: 'e2', category: 'execution', title: 'exec-002', meta: 'Completed 5m ago' },
{ id: 'a1', category: 'agent', title: 'camel-agent-prod-1', meta: 'live · 42 tps' },
{ id: 'x1', category: 'exchange', title: 'exch-aabb1122', meta: 'route: order-ingest' },
]
interface TableRow {
id: string
name: string
method: string
status: string
exchanges: number
}
const TABLE_DATA: TableRow[] = [
{ id: '1', name: 'order-ingest', method: 'POST', status: 'live', exchanges: 1243 },
{ id: '2', name: 'payment-validate', method: 'POST', status: 'live', exchanges: 987 },
{ id: '3', name: 'inventory-check', method: 'GET', status: 'stale', exchanges: 432 },
{ id: '4', name: 'notify-customer', method: 'POST', status: 'live', exchanges: 876 },
{ id: '5', name: 'archive-order', method: 'PUT', status: 'dead', exchanges: 54 },
]
const NOW = new Date()
const minsAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000)
const FEED_EVENTS = [
{ id: 'ev1', severity: 'success' as const, message: 'Route order-ingest started successfully', timestamp: minsAgo(1) },
{ id: 'ev2', severity: 'warning' as const, message: 'Agent camel-agent-prod-2 response time elevated', timestamp: minsAgo(3) },
{ id: 'ev3', severity: 'error' as const, message: 'Exchange exch-aabb1122 failed: timeout', timestamp: minsAgo(7) },
{ id: 'ev4', severity: 'running' as const, message: 'Processor payment-validate processing batch', timestamp: minsAgo(10) },
{ id: 'ev5', severity: 'success' as const, message: 'Deployment v3.2.1 completed', timestamp: minsAgo(15) },
]
const TREE_NODES = [
{
id: 'app1',
label: 'cameleer-prod',
icon: '⬡',
children: [
{
id: 'route1',
label: 'order-ingest',
icon: '→',
children: [
{ id: 'proc1', label: 'ValidateOrder', icon: '◈', meta: '12ms' },
{ id: 'proc2', label: 'EnrichPayload', icon: '◈', meta: '8ms' },
{ id: 'proc3', label: 'RouteToQueue', icon: '◈', meta: '3ms' },
],
},
{
id: 'route2',
label: 'payment-validate',
icon: '→',
children: [
{ id: 'proc4', label: 'TokenizeCard', icon: '◈', meta: '22ms' },
{ id: 'proc5', label: 'AuthorizePayment', icon: '◈', meta: '45ms' },
],
},
],
},
]
// ── CompositesSection ─────────────────────────────────────────────────────────
export function CompositesSection() {
// 1. Accordion
const accordionItems = [
{ id: 'a1', title: 'What is Apache Camel?', content: 'Apache Camel is an open-source integration framework based on enterprise integration patterns.' },
{ id: 'a2', title: 'How do routes work?', content: 'Routes define the path a message takes through the system, from consumer to producer.', defaultOpen: true },
{ id: 'a3', title: 'What are processors?', content: 'Processors transform, filter, enrich, or route messages as they flow through a route.' },
]
// 2. AlertDialog
const [alertOpen, setAlertOpen] = useState(false)
const [alertVariant, setAlertVariant] = useState<'danger' | 'warning' | 'info'>('danger')
// 7. CommandPalette
const [cmdOpen, setCmdOpen] = useState(false)
// 8. DataTable
const tableColumns = [
{ key: 'name', header: 'Route', sortable: true },
{ key: 'method', header: 'Method', sortable: true },
{ key: 'status', header: 'Status', sortable: true },
{ key: 'exchanges', header: 'Exchanges', sortable: true },
]
// 9. DetailPanel
const [panelOpen, setPanelOpen] = useState(false)
// 12. FilterBar
const filterOptions = [
{ label: 'Live', value: 'live', color: 'success' as const, count: 12 },
{ label: 'Stale', value: 'stale', count: 3 },
{ label: 'Dead', value: 'dead', color: 'error' as const, count: 1 },
]
const [activeFilters, setActiveFilters] = useState([{ label: 'Live', value: 'live' }])
// 15. Modal
const [modalOpen, setModalOpen] = useState(false)
// 19. Tabs
const tabItems = [
{ label: 'Overview', value: 'overview', count: undefined },
{ label: 'Routes', value: 'routes', count: 14 },
{ label: 'Agents', value: 'agents', count: 6 },
]
const [activeTab, setActiveTab] = useState('overview')
// 21. TreeView
const [selectedNode, setSelectedNode] = useState<string | undefined>('proc1')
return (
<ToastProvider>
<section id="composites" className={styles.section}>
<h2 className={styles.sectionTitle}>Composites</h2>
{/* 1. Accordion */}
<DemoCard
id="accordion"
title="Accordion"
description="Collapsible panels with single or multiple open mode."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<span className={styles.demoLabel}>Single mode (default)</span>
<Accordion items={accordionItems} />
<span className={styles.demoLabel}>Multiple mode</span>
<Accordion
items={[
{ id: 'm1', title: 'Section A', content: 'Content for section A.' },
{ id: 'm2', title: 'Section B', content: 'Content for section B.', defaultOpen: true },
{ id: 'm3', title: 'Section C', content: 'Content for section C.', defaultOpen: true },
]}
multiple
/>
</div>
</DemoCard>
{/* 2. AlertDialog */}
<DemoCard
id="alertdialog"
title="AlertDialog"
description="Modal confirmation dialog in danger, warning, and info variants."
>
<div className={styles.demoAreaRow}>
<Button
size="sm"
variant="danger"
onClick={() => { setAlertVariant('danger'); setAlertOpen(true) }}
>
Danger dialog
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => { setAlertVariant('warning'); setAlertOpen(true) }}
>
Warning dialog
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => { setAlertVariant('info'); setAlertOpen(true) }}
>
Info dialog
</Button>
</div>
<AlertDialog
open={alertOpen}
onClose={() => setAlertOpen(false)}
onConfirm={() => setAlertOpen(false)}
title={alertVariant === 'danger' ? 'Delete route?' : alertVariant === 'warning' ? 'Proceed with caution?' : 'Confirm action'}
description={
alertVariant === 'danger'
? 'This will permanently delete the route and all its exchange history. This action cannot be undone.'
: alertVariant === 'warning'
? 'This operation will restart all active processors. Running exchanges may be interrupted.'
: 'This will update the route configuration and apply changes immediately.'
}
variant={alertVariant}
confirmLabel={alertVariant === 'danger' ? 'Delete' : 'Confirm'}
/>
</DemoCard>
{/* 3. AreaChart */}
<DemoCard
id="areachart"
title="AreaChart"
description="SVG area chart with hover tooltip, threshold line, and legend."
>
<AreaChart series={CHART_SERIES} xLabel="Time (minutes)" yLabel="Count" width={420} height={180} />
</DemoCard>
{/* 4. AvatarGroup */}
<DemoCard
id="avatargroup"
title="AvatarGroup"
description="Stacked avatar group showing up to max avatars with an overflow count."
>
<div className={styles.demoAreaColumn}>
<span className={styles.demoLabel}>max=3, size=sm</span>
<AvatarGroup names={['Alice Johnson', 'Bob Smith', 'Carol White', 'David Lee', 'Eve Brown']} max={3} size="sm" />
<span className={styles.demoLabel}>max=4, size=md</span>
<AvatarGroup names={['Alice Johnson', 'Bob Smith', 'Carol White', 'David Lee', 'Eve Brown']} max={4} size="md" />
<span className={styles.demoLabel}>max=2, size=lg</span>
<AvatarGroup names={['Alice Johnson', 'Bob Smith', 'Carol White', 'David Lee', 'Eve Brown']} max={2} size="lg" />
</div>
</DemoCard>
{/* 5. BarChart */}
<DemoCard
id="barchart"
title="BarChart"
description="Bar chart with grouped and stacked modes, hover tooltips, and legend."
>
<div className={styles.demoAreaColumn}>
<span className={styles.demoLabel}>Grouped</span>
<BarChart series={BAR_SERIES} width={420} height={180} xLabel="Day" />
<span className={styles.demoLabel}>Stacked</span>
<BarChart series={BAR_SERIES} stacked width={420} height={180} xLabel="Day" />
</div>
</DemoCard>
{/* 6. Breadcrumb */}
<DemoCard
id="breadcrumb"
title="Breadcrumb"
description="Slash-separated navigation breadcrumb with linked and plain segments."
>
<Breadcrumb
items={[
{ label: 'Dashboard', href: '#' },
{ label: 'Applications', href: '#' },
{ label: 'order-ingest' },
]}
/>
</DemoCard>
{/* 7. CommandPalette */}
<DemoCard
id="commandpalette"
title="CommandPalette"
description="Full-screen search palette with category tabs, keyboard navigation, and highlight."
>
<Button size="sm" variant="secondary" onClick={() => setCmdOpen(true)}>
Open CommandPalette
</Button>
<CommandPalette
open={cmdOpen}
onClose={() => setCmdOpen(false)}
onSelect={() => setCmdOpen(false)}
data={COMMAND_PALETTE_DATA}
/>
</DemoCard>
{/* 8. DataTable */}
<DemoCard
id="datatable"
title="DataTable"
description="Sortable, paginated table with row click, accent rows, and page size selector."
>
<div style={{ width: '100%' }}>
<DataTable
columns={tableColumns}
data={TABLE_DATA}
sortable
pageSize={5}
/>
</div>
</DemoCard>
{/* 9. DetailPanel */}
<DemoCard
id="detailpanel"
title="DetailPanel"
description="Slide-in side panel with tabbed content and close button."
>
<Button size="sm" variant="secondary" onClick={() => setPanelOpen(true)}>
Open DetailPanel
</Button>
<DetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
title="Route: order-ingest"
tabs={[
{ label: 'Overview', value: 'overview', content: <div style={{ padding: '12px 0', fontSize: 13 }}>Route processes ~1,243 exchanges/day with avg latency 42ms.</div> },
{ label: 'Processors', value: 'processors', content: <div style={{ padding: '12px 0', fontSize: 13 }}>ValidateOrder EnrichPayload RouteToQueue</div> },
{ label: 'Errors', value: 'errors', content: <div style={{ padding: '12px 0', fontSize: 13 }}>3 errors in last 24h. Last: timeout at EnrichPayload.</div> },
]}
/>
</DemoCard>
{/* 10. Dropdown */}
<DemoCard
id="dropdown"
title="Dropdown"
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
>
<Dropdown
trigger={<Button size="sm" variant="secondary">Actions </Button>}
items={[
{ label: 'View details', icon: '👁', onClick: () => undefined },
{ label: 'Edit route', icon: '✏', onClick: () => undefined },
{ divider: true, label: '' },
{ label: 'Restart', icon: '↺', onClick: () => undefined },
{ label: 'Delete', icon: '✕', onClick: () => undefined, disabled: true },
]}
/>
</DemoCard>
{/* 11. EventFeed */}
<DemoCard
id="eventfeed"
title="EventFeed"
description="Scrollable live event log with severity filters and auto-scroll."
>
<div style={{ width: '100%' }}>
<EventFeed events={FEED_EVENTS} maxItems={10} />
</div>
</DemoCard>
{/* 12. FilterBar */}
<DemoCard
id="filterbar"
title="FilterBar"
description="Search input combined with filter pills and active filter tag chips."
>
<div style={{ width: '100%' }}>
<FilterBar
filters={filterOptions}
activeFilters={activeFilters}
onFilterChange={setActiveFilters}
searchPlaceholder="Search routes..."
/>
</div>
</DemoCard>
{/* 13. LineChart */}
<DemoCard
id="linechart"
title="LineChart"
description="Multi-series SVG line chart with hover crosshair, tooltip, and legend."
>
<LineChart series={CHART_SERIES} xLabel="Time (minutes)" yLabel="Count" width={420} height={180} />
</DemoCard>
{/* 14. MenuItem */}
<DemoCard
id="menuitem"
title="MenuItem"
description="Navigation menu row with health dot, meta text, count badge, and active state."
>
<div className={styles.demoAreaColumn} style={{ minWidth: 200 }}>
<MenuItem label="order-ingest" meta="POST route" count={1243} health="live" />
<MenuItem label="payment-validate" meta="POST route" count={987} health="live" active />
<MenuItem label="inventory-check" meta="GET route" count={432} health="stale" />
<MenuItem label="archive-order" meta="PUT route" count={54} health="dead" indent={1} />
</div>
</DemoCard>
{/* 15. Modal */}
<DemoCard
id="modal"
title="Modal"
description="Portal-rendered modal dialog in sm, md, and lg sizes with backdrop dismiss."
>
<Button size="sm" variant="secondary" onClick={() => setModalOpen(true)}>
Open Modal
</Button>
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title="Configure Route"
size="md"
>
<div style={{ fontSize: 13, lineHeight: 1.6 }}>
<p style={{ margin: '0 0 12px' }}>Adjust the route settings below. Changes will take effect immediately after saving.</p>
<p style={{ margin: 0, color: 'var(--text-muted)' }}>Route: order-ingest · Processor chain: 3 steps · Avg latency: 42ms</p>
</div>
</Modal>
</DemoCard>
{/* 16. Popover */}
<DemoCard
id="popover"
title="Popover"
description="Portal-positioned popover in all four positions with arrow indicator."
>
<div className={styles.demoAreaRow} style={{ paddingTop: 8, paddingBottom: 8 }}>
<Popover
position="top"
trigger={<Button size="sm" variant="secondary">Top</Button>}
content={<div style={{ padding: '8px 12px', fontSize: 12 }}>Popover on top</div>}
/>
<Popover
position="bottom"
trigger={<Button size="sm" variant="secondary">Bottom</Button>}
content={<div style={{ padding: '8px 12px', fontSize: 12 }}>Popover on bottom</div>}
/>
<Popover
position="left"
trigger={<Button size="sm" variant="secondary">Left</Button>}
content={<div style={{ padding: '8px 12px', fontSize: 12 }}>Popover on left</div>}
/>
<Popover
position="right"
trigger={<Button size="sm" variant="secondary">Right</Button>}
content={<div style={{ padding: '8px 12px', fontSize: 12 }}>Popover on right</div>}
/>
</div>
</DemoCard>
{/* 17. ProcessorTimeline */}
<DemoCard
id="processortimeline"
title="ProcessorTimeline"
description="Horizontal Gantt-style timeline showing processor execution order, duration, and status."
>
<div style={{ width: '100%' }}>
<ProcessorTimeline
totalMs={120}
processors={[
{ name: 'ValidateOrder', type: 'validator', durationMs: 12, status: 'ok', startMs: 0 },
{ name: 'EnrichPayload', type: 'enricher', durationMs: 35, status: 'slow', startMs: 12 },
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
]}
/>
</div>
</DemoCard>
{/* 18. ShortcutsBar */}
<DemoCard
id="shortcutsbar"
title="ShortcutsBar"
description="Row of keyboard shortcut hints, each pairing a key badge with a label."
>
<ShortcutsBar
shortcuts={[
{ keys: 'Ctrl+K', label: 'Search' },
{ keys: '↑↓', label: 'Navigate' },
{ keys: 'Enter', label: 'Open' },
{ keys: 'Esc', label: 'Close' },
{ keys: '?', label: 'Help' },
]}
/>
</DemoCard>
{/* 19. Tabs */}
<DemoCard
id="tabs"
title="Tabs"
description="Tab bar with optional count badges and active indicator."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<Tabs tabs={tabItems} active={activeTab} onChange={setActiveTab} />
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Active tab: <strong>{activeTab}</strong>
</div>
</div>
</DemoCard>
{/* 20. Toast */}
<DemoCard
id="toast"
title="Toast"
description="Stacked portal toast notifications in four variants with auto-dismiss and manual close."
>
<ToastDemo />
</DemoCard>
{/* 21. TreeView */}
<DemoCard
id="treeview"
title="TreeView"
description="Keyboard-navigable tree showing App → Routes → Processors hierarchy."
>
<TreeView
nodes={TREE_NODES}
selectedId={selectedNode}
onSelect={setSelectedNode}
/>
</DemoCard>
</section>
</ToastProvider>
)
}

View File

@@ -0,0 +1,139 @@
.section {
margin-bottom: 40px;
}
.sectionTitle {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 24px;
}
.componentCard {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
}
.componentTitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
display: flex;
align-items: center;
gap: 8px;
}
.componentDesc {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 16px;
}
.demoArea {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
}
.demoAreaColumn {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.demoAreaRow {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.demoLabel {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
}
.demoGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.demoGroupRow {
display: flex;
align-items: center;
gap: 8px;
}
/* AppShell diagram */
.shellDiagram {
width: 100%;
background: var(--bg-canvas);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
font-size: 11px;
color: var(--text-muted);
}
.shellDiagramTop {
border-bottom: 1px solid var(--border);
padding: 8px 12px;
background: var(--bg-overlay);
font-weight: 600;
color: var(--text-secondary);
}
.shellDiagramBody {
display: flex;
height: 140px;
}
.shellDiagramSide {
width: 140px;
border-right: 1px solid var(--border);
background: var(--bg-overlay);
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 4px;
font-weight: 600;
color: var(--text-secondary);
flex-shrink: 0;
}
.shellDiagramMain {
flex: 1;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-style: italic;
}
/* Sidebar preview container */
.sidebarPreview {
width: 220px;
height: 400px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
flex-shrink: 0;
}
/* TopBar preview container */
.topbarPreview {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}

View File

@@ -0,0 +1,140 @@
import styles from './LayoutSection.module.css'
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
// ── DemoCard helper ──────────────────────────────────────────────────────────
interface DemoCardProps {
id: string
title: string
description: string
children: React.ReactNode
}
function DemoCard({ id, title, description, children }: DemoCardProps) {
return (
<div id={id} className={styles.componentCard}>
<h3 className={styles.componentTitle}>{title}</h3>
<p className={styles.componentDesc}>{description}</p>
<div className={styles.demoArea}>{children}</div>
</div>
)
}
// ── Sample data ───────────────────────────────────────────────────────────────
const SAMPLE_APPS = [
{ id: 'app1', name: 'cameleer-prod', agentCount: 3, health: 'live' as const, execCount: 14320 },
{ id: 'app2', name: 'cameleer-staging', agentCount: 2, health: 'stale' as const, execCount: 871 },
{ id: 'app3', name: 'cameleer-dev', agentCount: 1, health: 'dead' as const, execCount: 42 },
]
const SAMPLE_ROUTES = [
{ id: 'r1', name: 'order-ingest', execCount: 5421 },
{ id: 'r2', name: 'payment-validate', execCount: 3102 },
{ id: 'r3', name: 'notify-customer', execCount: 2201 },
]
const SAMPLE_AGENTS = [
{
id: 'ag1',
name: 'agent-prod-1',
service: 'camel-core',
version: 'v3.2.1',
tps: '42 tps',
lastSeen: '1m ago',
status: 'live' as const,
},
{
id: 'ag2',
name: 'agent-prod-2',
service: 'camel-core',
version: 'v3.2.1',
tps: '38 tps',
lastSeen: '2m ago',
status: 'live' as const,
errorRate: '0.4%',
},
{
id: 'ag3',
name: 'agent-staging-1',
service: 'camel-core',
version: 'v3.1.9',
tps: '5 tps',
lastSeen: '8m ago',
status: 'stale' as const,
},
]
// ── LayoutSection ─────────────────────────────────────────────────────────────
export function LayoutSection() {
return (
<section id="layout" className={styles.section}>
<h2 className={styles.sectionTitle}>Layout</h2>
{/* 1. AppShell */}
<DemoCard
id="appshell"
title="AppShell"
description="Full-page shell that composes Sidebar + TopBar + main content area. Cannot be nested — shown as a structural diagram."
>
<div className={styles.shellDiagram}>
<div className={styles.shellDiagramTop}>
TopBar breadcrumb · search · env badge · shift · user avatar
</div>
<div className={styles.shellDiagramBody}>
<div className={styles.shellDiagramSide}>
<span>Sidebar</span>
<span style={{ fontWeight: 400, fontSize: 10, marginTop: 4 }}>Logo</span>
<span style={{ fontWeight: 400, fontSize: 10 }}>Search</span>
<span style={{ fontWeight: 400, fontSize: 10 }}>Navigation</span>
<span style={{ fontWeight: 400, fontSize: 10 }}>Applications</span>
<span style={{ fontWeight: 400, fontSize: 10 }}>Routes</span>
<span style={{ fontWeight: 400, fontSize: 10 }}>Agents</span>
</div>
<div className={styles.shellDiagramMain}>
&lt;children&gt; page content rendered here
</div>
</div>
</div>
</DemoCard>
{/* 2. Sidebar */}
<DemoCard
id="sidebar"
title="Sidebar"
description="Navigation sidebar with app/route/agent sections, search filter, health dots, and exec counts."
>
<div className={styles.sidebarPreview}>
<Sidebar
apps={SAMPLE_APPS}
routes={SAMPLE_ROUTES}
agents={SAMPLE_AGENTS}
/>
</div>
</DemoCard>
{/* 3. TopBar */}
<DemoCard
id="topbar"
title="TopBar"
description="Top navigation bar with breadcrumb, search trigger, environment badge, shift info, and user avatar."
>
<div className={styles.topbarPreview}>
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '#' },
{ label: 'Applications', href: '#' },
{ label: 'order-ingest' },
]}
environment="production"
shift="Morning"
user={{ name: 'Hendrik' }}
onSearchClick={() => undefined}
/>
</div>
</DemoCard>
</section>
)
}

View File

@@ -0,0 +1,74 @@
.section {
margin-bottom: 40px;
}
.sectionTitle {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 24px;
}
.componentCard {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
}
.componentTitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
display: flex;
align-items: center;
gap: 8px;
}
.componentDesc {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 16px;
}
.demoArea {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
}
.demoAreaColumn {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.demoAreaRow {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.demoLabel {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
}
.demoGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.demoGroupRow {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,591 @@
import { useState } from 'react'
import styles from './PrimitivesSection.module.css'
import {
Alert,
Avatar,
Badge,
Button,
Card,
Checkbox,
CodeBlock,
Collapsible,
DateRangePicker,
DateTimePicker,
EmptyState,
FilterPill,
FormField,
InfoCallout,
Input,
KeyboardHint,
Label,
MonoText,
Pagination,
ProgressBar,
RadioGroup,
RadioItem,
SectionHeader,
Select,
Skeleton,
Sparkline,
Spinner,
StatCard,
StatusDot,
Tag,
Textarea,
Toggle,
Tooltip,
} from '../../../design-system/primitives'
// ── helpers ──────────────────────────────────────────────────────────────────
interface DemoCardProps {
id: string
title: string
description: string
children: React.ReactNode
}
function DemoCard({ id, title, description, children }: DemoCardProps) {
return (
<div id={id} className={styles.componentCard}>
<h3 className={styles.componentTitle}>{title}</h3>
<p className={styles.componentDesc}>{description}</p>
<div className={styles.demoArea}>{children}</div>
</div>
)
}
// ── Sample data ───────────────────────────────────────────────────────────────
const SPARKLINE_DATA = [10, 25, 15, 30, 20, 35, 28]
const CODE_JSON = JSON.stringify(
{ status: 'ok', version: '2.4.1', routes: 42 },
null,
2,
)
// ── PrimitivesSection ─────────────────────────────────────────────────────────
export function PrimitivesSection() {
// Alert state
const [alertDismissed, setAlertDismissed] = useState(false)
// Checkbox state
const [checked1, setChecked1] = useState(false)
const [checked2, setChecked2] = useState(true)
// Toggle state
const [toggleOn, setToggleOn] = useState(true)
const [toggleOff, setToggleOff] = useState(false)
// Radio state
const [radioV, setRadioV] = useState('option-a')
const [radioH, setRadioH] = useState('beta')
// Pagination state
const [page, setPage] = useState(5)
// DateTimePicker state
const [dtValue, setDtValue] = useState<Date>(new Date('2026-03-18T09:00'))
// DateRangePicker state
const [dateRange, setDateRange] = useState({
start: new Date('2026-03-11T00:00'),
end: new Date('2026-03-18T23:59'),
})
return (
<section id="primitives" className={styles.section}>
<h2 className={styles.sectionTitle}>Primitives</h2>
{/* 1. Alert */}
<DemoCard
id="alert"
title="Alert"
description="Contextual feedback messages in four variants, optionally dismissible."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<Alert variant="info" title="Info">This is an informational message.</Alert>
<Alert variant="success" title="Success">Operation completed successfully.</Alert>
<Alert variant="warning" title="Warning">This action may have side effects.</Alert>
{!alertDismissed && (
<Alert
variant="error"
title="Error"
dismissible
onDismiss={() => setAlertDismissed(true)}
>
Something went wrong. Dismiss to clear.
</Alert>
)}
{alertDismissed && (
<Button size="sm" variant="ghost" onClick={() => setAlertDismissed(false)}>
Reset dismissed alert
</Button>
)}
</div>
</DemoCard>
{/* 2. Avatar */}
<DemoCard
id="avatar"
title="Avatar"
description="Initials-based avatar with hash-derived colour, three sizes."
>
<Avatar name="Alice Johnson" size="sm" />
<Avatar name="Bob Smith" size="md" />
<Avatar name="Carol White" size="lg" />
</DemoCard>
{/* 3. Badge */}
<DemoCard
id="badge"
title="Badge"
description="Compact label with semantic colours and a dashed variant."
>
<Badge label="primary" color="primary" />
<Badge label="success" color="success" />
<Badge label="warning" color="warning" />
<Badge label="error" color="error" />
<Badge label="running" color="running" />
<Badge label="auto-hash" color="auto" />
<Badge label="dashed" color="primary" variant="dashed" />
</DemoCard>
{/* 4. Button */}
<DemoCard
id="button"
title="Button"
description="Primary, secondary, danger, and ghost variants across two sizes plus loading state."
>
<div className={styles.demoAreaColumn}>
<div className={styles.demoAreaRow}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="danger">Danger</Button>
<Button variant="ghost">Ghost</Button>
</div>
<div className={styles.demoAreaRow}>
<Button variant="primary" size="sm">Small</Button>
<Button variant="primary" size="md">Medium</Button>
<Button variant="primary" loading>Loading</Button>
</div>
</div>
</DemoCard>
{/* 5. Card */}
<DemoCard
id="card"
title="Card"
description="Surface container with optional left-border accent colour."
>
<Card><div style={{ padding: '8px 12px', fontSize: 13 }}>Plain card</div></Card>
<Card accent="amber"><div style={{ padding: '8px 12px', fontSize: 13 }}>Amber accent</div></Card>
<Card accent="success"><div style={{ padding: '8px 12px', fontSize: 13 }}>Success accent</div></Card>
<Card accent="error"><div style={{ padding: '8px 12px', fontSize: 13 }}>Error accent</div></Card>
</DemoCard>
{/* 6. Checkbox */}
<DemoCard
id="checkbox"
title="Checkbox"
description="Accessible checkbox with optional label, controlled and disabled states."
>
<Checkbox
label="Unchecked"
checked={checked1}
onChange={(e) => setChecked1(e.target.checked)}
/>
<Checkbox
label="Checked"
checked={checked2}
onChange={(e) => setChecked2(e.target.checked)}
/>
<Checkbox label="Disabled" disabled />
<Checkbox label="Disabled checked" disabled defaultChecked />
</DemoCard>
{/* 7. CodeBlock */}
<DemoCard
id="codeblock"
title="CodeBlock"
description="Syntax-highlighted code block with line numbers and copy button."
>
<div style={{ width: '100%' }}>
<CodeBlock
content={CODE_JSON}
language="json"
lineNumbers
copyable
/>
</div>
</DemoCard>
{/* 8. Collapsible */}
<DemoCard
id="collapsible"
title="Collapsible"
description="Animated accordion-style disclosure, open and closed by default."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<Collapsible title="Collapsed by default">
<p style={{ margin: 0, fontSize: 13 }}>Hidden content revealed on expand.</p>
</Collapsible>
<Collapsible title="Open by default" defaultOpen>
<p style={{ margin: 0, fontSize: 13 }}>This content is visible from the start.</p>
</Collapsible>
</div>
</DemoCard>
{/* 9. DateTimePicker */}
<DemoCard
id="datetimepicker"
title="DateTimePicker"
description="Native datetime-local input wrapped with label and controlled value."
>
<DateTimePicker
label="Pick a date & time"
value={dtValue}
onChange={(d) => d && setDtValue(d)}
/>
</DemoCard>
{/* 10. DateRangePicker */}
<DemoCard
id="daterangepicker"
title="DateRangePicker"
description="Preset pills combined with two DateTimePicker inputs for a start/end range."
>
<div style={{ width: '100%' }}>
<DateRangePicker value={dateRange} onChange={setDateRange} />
</div>
</DemoCard>
{/* 11. EmptyState */}
<DemoCard
id="emptystate"
title="EmptyState"
description="Zero-data placeholder with icon, title, description, and action slot."
>
<EmptyState
icon={<span style={{ fontSize: 28 }}>📭</span>}
title="No results found"
description="Try adjusting your filters or search query."
action={<Button size="sm" variant="secondary">Clear filters</Button>}
/>
</DemoCard>
{/* 12. FilterPill */}
<DemoCard
id="filterpill"
title="FilterPill"
description="Toggle-style pill for filter UIs, with optional dot indicator and count."
>
<FilterPill label="Active" active />
<FilterPill label="Inactive" />
<FilterPill label="With dot" dot active />
<FilterPill label="Count" count={42} />
</DemoCard>
{/* 13. FormField */}
<DemoCard
id="formfield"
title="FormField"
description="Field wrapper providing a label, hint text, and inline error messaging."
>
<div className={styles.demoAreaColumn} style={{ minWidth: 240 }}>
<FormField label="Username" htmlFor="inv-username" hint="Letters and numbers only.">
<Input id="inv-username" placeholder="e.g. alice42" />
</FormField>
<FormField label="Email" htmlFor="inv-email" required error="Invalid email address.">
<Input id="inv-email" placeholder="you@example.com" />
</FormField>
</div>
</DemoCard>
{/* 14. InfoCallout */}
<DemoCard
id="infocallout"
title="InfoCallout"
description="Bordered callout block in amber, success, warning, and error flavours."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<InfoCallout variant="amber" title="Amber">Review before publishing.</InfoCallout>
<InfoCallout variant="success" title="Success">Deployment completed.</InfoCallout>
<InfoCallout variant="warning" title="Warning">Rate limit approaching.</InfoCallout>
<InfoCallout variant="error" title="Error">Build failed check logs.</InfoCallout>
</div>
</DemoCard>
{/* 15. Input */}
<DemoCard
id="input"
title="Input"
description="Text input with optional leading icon and placeholder."
>
<Input placeholder="Plain input" />
<Input icon="🔍" placeholder="With icon" />
</DemoCard>
{/* 16. KeyboardHint */}
<DemoCard
id="keyboardhint"
title="KeyboardHint"
description="Styled keyboard shortcut badge."
>
<KeyboardHint keys="Ctrl+K" />
<KeyboardHint keys="⌘+P" />
<KeyboardHint keys="Esc" />
</DemoCard>
{/* 17. Label */}
<DemoCard
id="label"
title="Label"
description="Form label with optional required asterisk."
>
<Label>Plain label</Label>
<Label required>Required label</Label>
</DemoCard>
{/* 18. MonoText */}
<DemoCard
id="monotext"
title="MonoText"
description="Monospaced text in xs, sm, and md sizes."
>
<MonoText size="xs">xs: route-id-001</MonoText>
<MonoText size="sm">sm: route-id-001</MonoText>
<MonoText size="md">md: route-id-001</MonoText>
</DemoCard>
{/* 19. Pagination */}
<DemoCard
id="pagination"
title="Pagination"
description="Page navigation with ellipsis and sibling-window algorithm."
>
<Pagination
page={page}
totalPages={20}
onPageChange={setPage}
/>
</DemoCard>
{/* 20. ProgressBar */}
<DemoCard
id="progressbar"
title="ProgressBar"
description="Determinate and indeterminate bars across variants and sizes."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<ProgressBar value={60} label="60% complete" />
<ProgressBar value={60} variant="success" label="Success variant" />
<ProgressBar value={60} variant="warning" label="Warning variant" />
<ProgressBar value={60} variant="error" label="Error variant" />
<ProgressBar value={60} variant="running" label="Running variant" />
<ProgressBar indeterminate label="Indeterminate" />
<ProgressBar value={60} size="sm" label="Small size" />
</div>
</DemoCard>
{/* 21. Radio */}
<DemoCard
id="radio"
title="Radio"
description="RadioGroup with vertical and horizontal orientations."
>
<RadioGroup name="inv-radio-v" value={radioV} onChange={setRadioV} orientation="vertical">
<RadioItem value="option-a" label="Option A" />
<RadioItem value="option-b" label="Option B" />
<RadioItem value="option-c" label="Option C (disabled)" disabled />
</RadioGroup>
<RadioGroup name="inv-radio-h" value={radioH} onChange={setRadioH} orientation="horizontal">
<RadioItem value="alpha" label="Alpha" />
<RadioItem value="beta" label="Beta" />
<RadioItem value="gamma" label="Gamma" />
</RadioGroup>
</DemoCard>
{/* 22. SectionHeader */}
<DemoCard
id="sectionheader"
title="SectionHeader"
description="Labelled horizontal divider with an optional right-side action slot."
>
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
<SectionHeader>Without action</SectionHeader>
<SectionHeader action={<Button size="sm" variant="ghost">Add item</Button>}>
With action
</SectionHeader>
</div>
</DemoCard>
{/* 23. Select */}
<DemoCard
id="select"
title="Select"
description="Styled native select with custom chevron."
>
<Select
options={[
{ value: 'opt1', label: 'Option 1' },
{ value: 'opt2', label: 'Option 2' },
{ value: 'opt3', label: 'Option 3' },
]}
/>
</DemoCard>
{/* 24. Skeleton */}
<DemoCard
id="skeleton"
title="Skeleton"
description="Loading placeholder in text, circular, and rectangular variants."
>
<div className={styles.demoAreaColumn}>
<Skeleton variant="text" lines={3} width={200} />
<Skeleton variant="circular" />
<Skeleton variant="rectangular" width={200} height={60} />
</div>
</DemoCard>
{/* 25. Sparkline */}
<DemoCard
id="sparkline"
title="Sparkline"
description="Lightweight SVG line chart for inline trend visualisation."
>
<Sparkline data={SPARKLINE_DATA} width={120} height={30} />
<Sparkline data={SPARKLINE_DATA} color="var(--success)" width={120} height={30} />
<Sparkline data={SPARKLINE_DATA} color="var(--error)" width={120} height={30} />
</DemoCard>
{/* 26. Spinner */}
<DemoCard
id="spinner"
title="Spinner"
description="Animated loading indicator in sm, md, and lg sizes."
>
<Spinner size="sm" />
<Spinner size="md" />
<Spinner size="lg" />
</DemoCard>
{/* 27. StatCard */}
<DemoCard
id="statcard"
title="StatCard"
description="Metric tile with label, value, trend indicator, and inline sparkline."
>
<StatCard
label="Throughput"
value="1,433"
detail="msg/s"
trend="up"
trendValue="+12%"
accent="amber"
sparkline={SPARKLINE_DATA}
/>
<StatCard
label="Error rate"
value="0.4%"
trend="down"
trendValue="-0.1pp"
accent="error"
/>
<StatCard
label="Agents"
value="6"
detail="all healthy"
trend="neutral"
trendValue="±0"
accent="success"
/>
</DemoCard>
{/* 28. StatusDot */}
<DemoCard
id="statusdot"
title="StatusDot"
description="Small coloured dot for all status variants, with optional pulse animation."
>
{(['live', 'stale', 'dead', 'success', 'warning', 'error', 'running'] as const).map(
(v) => (
<div key={v} className={styles.demoGroupRow}>
<StatusDot variant={v} />
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{v}</span>
</div>
),
)}
<div className={styles.demoGroupRow}>
<StatusDot variant="live" pulse />
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>live + pulse</span>
</div>
</DemoCard>
{/* 29. Tag */}
<DemoCard
id="tag"
title="Tag"
description="Keyword label with semantic or auto hash colour, and removable variant."
>
<Tag label="primary" color="primary" />
<Tag label="success" color="success" />
<Tag label="warning" color="warning" />
<Tag label="error" color="error" />
<Tag label="auto-hash" color="auto" />
<Tag label="removable" color="primary" onRemove={() => undefined} />
</DemoCard>
{/* 30. Textarea */}
<DemoCard
id="textarea"
title="Textarea"
description="Multi-line text input with vertical resize by default."
>
<Textarea placeholder="Enter a description…" style={{ width: 280 }} />
</DemoCard>
{/* 31. Toggle */}
<DemoCard
id="toggle"
title="Toggle"
description="On/off switch with optional label."
>
<Toggle
label="Enabled"
checked={toggleOn}
onChange={(e) => setToggleOn(e.target.checked)}
/>
<Toggle
label="Disabled off"
checked={toggleOff}
onChange={(e) => setToggleOff(e.target.checked)}
/>
<Toggle label="Locked on" checked disabled />
<Toggle label="Locked off" disabled />
</DemoCard>
{/* 32. Tooltip */}
<DemoCard
id="tooltip"
title="Tooltip"
description="CSS-only hover tooltip in all four positions."
>
<Tooltip content="Top tooltip" position="top">
<Button size="sm" variant="secondary">Top</Button>
</Tooltip>
<Tooltip content="Bottom tooltip" position="bottom">
<Button size="sm" variant="secondary">Bottom</Button>
</Tooltip>
<Tooltip content="Left tooltip" position="left">
<Button size="sm" variant="secondary">Left</Button>
</Tooltip>
<Tooltip content="Right tooltip" position="right">
<Button size="sm" variant="secondary">Right</Button>
</Tooltip>
</DemoCard>
</section>
)
}