Merge branch 'feature/design-system-gap-fill'
This commit is contained in:
44
CLAUDE.md
Normal file
44
CLAUDE.md
Normal 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
218
COMPONENT_GUIDE.md
Normal 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
|
||||
@@ -126,6 +126,7 @@ src/
|
||||
│ ├── utils/
|
||||
│ │ └── hashColor.ts # Name → HSL deterministic color
|
||||
│ ├── primitives/
|
||||
│ │ ├── Alert/
|
||||
│ │ ├── Avatar/
|
||||
│ │ │ ├── Avatar.tsx
|
||||
│ │ │ └── Avatar.module.css
|
||||
@@ -139,22 +140,32 @@ src/
|
||||
│ │ ├── DateRangePicker/
|
||||
│ │ ├── EmptyState/
|
||||
│ │ ├── FilterPill/
|
||||
│ │ ├── FormField/
|
||||
│ │ ├── InfoCallout/
|
||||
│ │ ├── Input/
|
||||
│ │ ├── KeyboardHint/
|
||||
│ │ ├── Label/
|
||||
│ │ ├── MonoText/
|
||||
│ │ ├── Pagination/
|
||||
│ │ ├── ProgressBar/
|
||||
│ │ ├── Radio/
|
||||
│ │ ├── Select/
|
||||
│ │ ├── SectionHeader/
|
||||
│ │ ├── Skeleton/
|
||||
│ │ ├── Sparkline/
|
||||
│ │ ├── Spinner/
|
||||
│ │ ├── StatCard/
|
||||
│ │ ├── StatusDot/
|
||||
│ │ ├── Tag/
|
||||
│ │ ├── Textarea/
|
||||
│ │ ├── Toggle/
|
||||
│ │ ├── Tooltip/
|
||||
│ │ └── index.ts # Barrel export
|
||||
│ ├── composites/
|
||||
│ │ ├── Accordion/
|
||||
│ │ ├── AlertDialog/
|
||||
│ │ ├── AreaChart/
|
||||
│ │ ├── AvatarGroup/
|
||||
│ │ ├── BarChart/
|
||||
│ │ ├── Breadcrumb/
|
||||
│ │ ├── CommandPalette/
|
||||
@@ -166,9 +177,12 @@ src/
|
||||
│ │ ├── LineChart/
|
||||
│ │ ├── MenuItem/
|
||||
│ │ ├── Modal/
|
||||
│ │ ├── Popover/
|
||||
│ │ ├── ProcessorTimeline/
|
||||
│ │ ├── ShortcutsBar/
|
||||
│ │ ├── Tabs/
|
||||
│ │ ├── Toast/
|
||||
│ │ ├── TreeView/
|
||||
│ │ └── index.ts
|
||||
│ ├── layout/
|
||||
│ │ ├── AppShell/
|
||||
@@ -182,7 +196,8 @@ src/
|
||||
│ ├── Metrics/
|
||||
│ ├── RouteDetail/
|
||||
│ ├── ExchangeDetail/
|
||||
│ └── AgentHealth/
|
||||
│ ├── AgentHealth/
|
||||
│ └── Inventory/ # Component showcase at /inventory
|
||||
├── mocks/ # Static TypeScript mock data
|
||||
│ ├── exchanges.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
|
||||
- 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
|
||||
|
||||
#### Breadcrumb
|
||||
@@ -469,6 +535,52 @@ All CSS custom properties for both themes. Light values sourced from `mock-v2-li
|
||||
- Each hint: `KeyboardHint` + description text
|
||||
- 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
|
||||
|
||||
#### 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` |
|
||||
| `/exchanges/:id` | ExchangeDetail | Message inspector from `mock-v3-exchange-detail` |
|
||||
| `/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.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Metrics } from './pages/Metrics/Metrics'
|
||||
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
|
||||
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
|
||||
import { AgentHealth } from './pages/AgentHealth/AgentHealth'
|
||||
import { Inventory } from './pages/Inventory/Inventory'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -13,6 +14,7 @@ export default function App() {
|
||||
<Route path="/routes/:id" element={<RouteDetail />} />
|
||||
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
||||
<Route path="/agents" element={<AgentHealth />} />
|
||||
<Route path="/inventory" element={<Inventory />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
28
src/design-system/composites/Accordion/Accordion.module.css
Normal file
28
src/design-system/composites/Accordion/Accordion.module.css
Normal 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);
|
||||
}
|
||||
105
src/design-system/composites/Accordion/Accordion.test.tsx
Normal file
105
src/design-system/composites/Accordion/Accordion.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
74
src/design-system/composites/Accordion/Accordion.tsx
Normal file
74
src/design-system/composites/Accordion/Accordion.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
86
src/design-system/composites/AlertDialog/AlertDialog.tsx
Normal file
86
src/design-system/composites/AlertDialog/AlertDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
32
src/design-system/composites/AvatarGroup/AvatarGroup.tsx
Normal file
32
src/design-system/composites/AvatarGroup/AvatarGroup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
src/design-system/composites/Popover/Popover.module.css
Normal file
107
src/design-system/composites/Popover/Popover.module.css
Normal 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;
|
||||
}
|
||||
108
src/design-system/composites/Popover/Popover.test.tsx
Normal file
108
src/design-system/composites/Popover/Popover.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
146
src/design-system/composites/Popover/Popover.tsx
Normal file
146
src/design-system/composites/Popover/Popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
144
src/design-system/composites/Toast/Toast.module.css
Normal file
144
src/design-system/composites/Toast/Toast.module.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* ── Container ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
/* newest at bottom — column order matches DOM order */
|
||||
}
|
||||
|
||||
/* ── Toast item ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 300px;
|
||||
max-width: 420px;
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
border-left: 4px solid transparent;
|
||||
font-family: var(--font-body);
|
||||
animation: slideIn 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
.dismissing {
|
||||
animation: fadeOut 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
/* ── Variant accent colors ────────────────────────────────────────────────── */
|
||||
|
||||
.success {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.success .icon {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.warning {
|
||||
border-left-color: var(--warning);
|
||||
}
|
||||
|
||||
.warning .icon {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.error {
|
||||
border-left-color: var(--error);
|
||||
}
|
||||
|
||||
.error .icon {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.info {
|
||||
border-left-color: var(--running);
|
||||
}
|
||||
|
||||
.info .icon {
|
||||
color: var(--running);
|
||||
}
|
||||
|
||||
/* ── Icon ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.icon {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Content ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Close button ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.closeBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ── Animations ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
252
src/design-system/composites/Toast/Toast.test.tsx
Normal file
252
src/design-system/composites/Toast/Toast.test.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, act, fireEvent } from '@testing-library/react'
|
||||
import { ToastProvider, useToast } from './Toast'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Renders a ToastProvider and exposes the useToast API via a ref-like object */
|
||||
function renderProvider() {
|
||||
let api!: ReturnType<typeof useToast>
|
||||
|
||||
function Consumer() {
|
||||
api = useToast()
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Consumer />
|
||||
</ToastProvider>,
|
||||
)
|
||||
|
||||
return { getApi: () => api }
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Toast', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows a toast when toast() is called', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Hello', variant: 'info' }) })
|
||||
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders title and description', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Success!', variant: 'success', description: 'It worked.' }) })
|
||||
|
||||
expect(screen.getByText('Success!')).toBeInTheDocument()
|
||||
expect(screen.getByText('It worked.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct variant data attribute — error', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Error!', variant: 'error' }) })
|
||||
|
||||
expect(screen.getByTestId('toast')).toHaveAttribute('data-variant', 'error')
|
||||
})
|
||||
|
||||
it('applies success variant', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Success!', variant: 'success' }) })
|
||||
|
||||
expect(screen.getByTestId('toast')).toHaveAttribute('data-variant', 'success')
|
||||
})
|
||||
|
||||
it('applies warning variant', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Warning!', variant: 'warning' }) })
|
||||
|
||||
expect(screen.getByTestId('toast')).toHaveAttribute('data-variant', 'warning')
|
||||
})
|
||||
|
||||
it('applies info variant by default when no variant given', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Default' }) })
|
||||
|
||||
expect(screen.getByTestId('toast')).toHaveAttribute('data-variant', 'info')
|
||||
})
|
||||
|
||||
it('shows correct icon for info variant', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
||||
|
||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct icon for success variant', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
||||
|
||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct icon for warning variant', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
||||
|
||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct icon for error variant', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
||||
|
||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismisses toast when close button is clicked', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Closeable' }) })
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Dismiss notification'))
|
||||
|
||||
// After exit animation duration
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
|
||||
expect(screen.queryByTestId('toast')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('auto-dismisses after default duration (5000ms)', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Auto' }) })
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument()
|
||||
|
||||
// Advance past default duration + exit animation
|
||||
act(() => { vi.advanceTimersByTime(5000 + 300) })
|
||||
|
||||
expect(screen.queryByTestId('toast')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is still visible just before auto-dismiss fires', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Auto', duration: 1000 }) })
|
||||
|
||||
act(() => { vi.advanceTimersByTime(999) })
|
||||
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('auto-dismisses after custom duration', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Fast', duration: 1000 }) })
|
||||
|
||||
act(() => { vi.advanceTimersByTime(1000 + 300) })
|
||||
|
||||
expect(screen.queryByTestId('toast')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders multiple toasts', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => {
|
||||
getApi().toast({ title: 'First', variant: 'info' })
|
||||
getApi().toast({ title: 'Second', variant: 'error' })
|
||||
getApi().toast({ title: 'Third', variant: 'warning' })
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('toast')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('caps visible toasts at 5', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
getApi().toast({ title: `Toast ${i}` })
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('toast')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('keeps newest 5 when capped', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
getApi().toast({ title: `Toast ${i}` })
|
||||
}
|
||||
})
|
||||
|
||||
// Toast 0 and 1 should be gone (oldest), Toast 2-6 remain
|
||||
expect(screen.queryByText('Toast 0')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Toast 1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Toast 6')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns a string id from toast()', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
let id!: string
|
||||
act(() => { id = getApi().toast({ title: 'Test' }) })
|
||||
|
||||
expect(typeof id).toBe('string')
|
||||
expect(id.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('dismiss() by id removes only the specified toast', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
let id1!: string
|
||||
act(() => {
|
||||
id1 = getApi().toast({ title: 'First' })
|
||||
getApi().toast({ title: 'Second' })
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('toast')).toHaveLength(2)
|
||||
|
||||
act(() => { getApi().dismiss(id1) })
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
|
||||
expect(screen.getAllByTestId('toast')).toHaveLength(1)
|
||||
expect(screen.queryByText('First')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toast container has aria-live attribute', () => {
|
||||
const { getApi } = renderProvider()
|
||||
|
||||
act(() => { getApi().toast({ title: 'Accessible' }) })
|
||||
|
||||
expect(screen.getByLabelText('Notifications')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Notifications')).toHaveAttribute('aria-live', 'polite')
|
||||
})
|
||||
|
||||
it('throws if useToast is used outside ToastProvider', () => {
|
||||
function BadComponent() {
|
||||
useToast()
|
||||
return null
|
||||
}
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(<BadComponent />)).toThrow('useToast must be used within a ToastProvider')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
190
src/design-system/composites/Toast/Toast.tsx
Normal file
190
src/design-system/composites/Toast/Toast.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './Toast.module.css'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ToastVariant = 'success' | 'warning' | 'error' | 'info'
|
||||
|
||||
export interface ToastOptions {
|
||||
title: string
|
||||
description?: string
|
||||
variant?: ToastVariant
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastItem extends Required<Pick<ToastOptions, 'title' | 'variant' | 'duration'>> {
|
||||
id: string
|
||||
description?: string
|
||||
/** when true, plays the exit animation before removal */
|
||||
dismissing: boolean
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (options: ToastOptions) => string
|
||||
dismiss: (id: string) => void
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_TOASTS = 5
|
||||
const DEFAULT_DURATION = 5000
|
||||
const EXIT_ANIMATION_MS = 300
|
||||
|
||||
const ICONS: Record<ToastVariant, string> = {
|
||||
info: 'ℹ',
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
error: '✕',
|
||||
}
|
||||
|
||||
// ── Context ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
// ── ToastProvider ──────────────────────────────────────────────────────────
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
// Clear auto-dismiss timer if running
|
||||
const timer = timersRef.current.get(id)
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
timersRef.current.delete(id)
|
||||
}
|
||||
|
||||
// Mark as dismissing (triggers exit animation)
|
||||
setToasts((prev) =>
|
||||
prev.map((t) => (t.id === id ? { ...t, dismissing: true } : t)),
|
||||
)
|
||||
|
||||
// Remove after animation completes
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, EXIT_ANIMATION_MS)
|
||||
}, [])
|
||||
|
||||
const toast = useCallback(
|
||||
(options: ToastOptions): string => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
const duration = options.duration ?? DEFAULT_DURATION
|
||||
const variant = options.variant ?? 'info'
|
||||
|
||||
const newToast: ToastItem = {
|
||||
id,
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
variant,
|
||||
duration,
|
||||
dismissing: false,
|
||||
}
|
||||
|
||||
setToasts((prev) => {
|
||||
const next = [...prev, newToast]
|
||||
// Keep only the last MAX_TOASTS entries (newest at the end)
|
||||
return next.slice(-MAX_TOASTS)
|
||||
})
|
||||
|
||||
// Schedule auto-dismiss
|
||||
const timer = setTimeout(() => {
|
||||
dismiss(id)
|
||||
}, duration)
|
||||
timersRef.current.set(id, timer)
|
||||
|
||||
return id
|
||||
},
|
||||
[dismiss],
|
||||
)
|
||||
|
||||
// Clean up all timers on unmount
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current
|
||||
return () => {
|
||||
timers.forEach(clearTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast, dismiss }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onDismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── useToast ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ── ToastContainer (portal) ────────────────────────────────────────────────
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: ToastItem[]
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
|
||||
function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.container} aria-live="polite" aria-label="Notifications">
|
||||
{toasts.map((t) => (
|
||||
<ToastItemComponent key={t.id} toast={t} onDismiss={onDismiss} />
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
// ── ToastItem ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface ToastItemComponentProps {
|
||||
toast: ToastItem
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
|
||||
function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${toast.dismissing ? styles.dismissing : ''}`}
|
||||
role="alert"
|
||||
data-testid="toast"
|
||||
data-variant={toast.variant}
|
||||
>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{ICONS[toast.variant]}
|
||||
</span>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.title}>{toast.title}</div>
|
||||
{toast.description && (
|
||||
<div className={styles.description}>{toast.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={styles.closeBtn}
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
src/design-system/composites/TreeView/TreeView.module.css
Normal file
99
src/design-system/composites/TreeView/TreeView.module.css
Normal 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;
|
||||
}
|
||||
302
src/design-system/composites/TreeView/TreeView.test.tsx
Normal file
302
src/design-system/composites/TreeView/TreeView.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
289
src/design-system/composites/TreeView/TreeView.tsx
Normal file
289
src/design-system/composites/TreeView/TreeView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export { Accordion } from './Accordion/Accordion'
|
||||
export { AlertDialog } from './AlertDialog/AlertDialog'
|
||||
export { AreaChart } from './AreaChart/AreaChart'
|
||||
export { AvatarGroup } from './AvatarGroup/AvatarGroup'
|
||||
export { BarChart } from './BarChart/BarChart'
|
||||
export { Breadcrumb } from './Breadcrumb/Breadcrumb'
|
||||
export { CommandPalette } from './CommandPalette/CommandPalette'
|
||||
@@ -13,7 +16,10 @@ export { FilterBar } from './FilterBar/FilterBar'
|
||||
export { LineChart } from './LineChart/LineChart'
|
||||
export { MenuItem } from './MenuItem/MenuItem'
|
||||
export { Modal } from './Modal/Modal'
|
||||
export { Popover } from './Popover/Popover'
|
||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||
export { Tabs } from './Tabs/Tabs'
|
||||
export { ToastProvider, useToast } from './Toast/Toast'
|
||||
export { TreeView } from './TreeView/TreeView'
|
||||
|
||||
93
src/design-system/primitives/Alert/Alert.module.css
Normal file
93
src/design-system/primitives/Alert/Alert.module.css
Normal file
@@ -0,0 +1,93 @@
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
border-left: 4px solid transparent;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-body);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.info {
|
||||
background: var(--running-bg);
|
||||
border-left-color: var(--running);
|
||||
}
|
||||
|
||||
.success {
|
||||
background: var(--success-bg);
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--warning-bg);
|
||||
border-left-color: var(--warning);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--error-bg);
|
||||
border-left-color: var(--error);
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info .icon { color: var(--running); }
|
||||
.success .icon { color: var(--success); }
|
||||
.warning .icon { color: var(--warning); }
|
||||
.error .icon { color: var(--error); }
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.info .title { color: var(--running); }
|
||||
.success .title { color: var(--success); }
|
||||
.warning .title { color: var(--warning); }
|
||||
.error .title { color: var(--error); }
|
||||
|
||||
.body {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Dismiss button */
|
||||
.dismissBtn {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dismissBtn:hover {
|
||||
background: var(--border);
|
||||
color: var(--text-primary, var(--text-secondary));
|
||||
}
|
||||
100
src/design-system/primitives/Alert/Alert.test.tsx
Normal file
100
src/design-system/primitives/Alert/Alert.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Alert } from './Alert'
|
||||
|
||||
describe('Alert', () => {
|
||||
it('renders children', () => {
|
||||
render(<Alert>Something went wrong</Alert>)
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders title when provided', () => {
|
||||
render(<Alert title="Heads up">Body text</Alert>)
|
||||
expect(screen.getByText('Heads up')).toBeInTheDocument()
|
||||
expect(screen.getByText('Body text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without title', () => {
|
||||
render(<Alert>Just a message</Alert>)
|
||||
expect(screen.getByText('Just a message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses role="alert" for error variant', () => {
|
||||
render(<Alert variant="error">Error message</Alert>)
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses role="alert" for warning variant', () => {
|
||||
render(<Alert variant="warning">Warning message</Alert>)
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses role="status" for info variant', () => {
|
||||
render(<Alert variant="info">Info message</Alert>)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses role="status" for success variant', () => {
|
||||
render(<Alert variant="success">Success message</Alert>)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to info variant (role="status")', () => {
|
||||
render(<Alert>Default alert</Alert>)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows default icon for each variant', () => {
|
||||
const { rerender } = render(<Alert variant="info">msg</Alert>)
|
||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
||||
|
||||
rerender(<Alert variant="success">msg</Alert>)
|
||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
||||
|
||||
rerender(<Alert variant="warning">msg</Alert>)
|
||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
||||
|
||||
rerender(<Alert variant="error">msg</Alert>)
|
||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a custom icon when provided', () => {
|
||||
render(<Alert icon={<span>★</span>}>Custom icon alert</Alert>)
|
||||
expect(screen.getByText('★')).toBeInTheDocument()
|
||||
// Default icon should not appear
|
||||
expect(screen.queryByText('ℹ')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dismiss button when dismissible is false', () => {
|
||||
render(<Alert>Non-dismissible</Alert>)
|
||||
expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows dismiss button when dismissible is true', () => {
|
||||
render(<Alert dismissible>Dismissible alert</Alert>)
|
||||
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onDismiss when dismiss button is clicked', async () => {
|
||||
const onDismiss = vi.fn()
|
||||
render(
|
||||
<Alert dismissible onDismiss={onDismiss}>
|
||||
Dismissible alert
|
||||
</Alert>,
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }))
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies a custom className', () => {
|
||||
const { container } = render(<Alert className="my-custom-class">Alert</Alert>)
|
||||
expect(container.firstChild).toHaveClass('my-custom-class')
|
||||
})
|
||||
|
||||
it('applies the correct variant class', () => {
|
||||
const { container } = render(<Alert variant="error">Error</Alert>)
|
||||
expect(container.firstChild).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
69
src/design-system/primitives/Alert/Alert.tsx
Normal file
69
src/design-system/primitives/Alert/Alert.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ReactNode } from 'react'
|
||||
import styles from './Alert.module.css'
|
||||
|
||||
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
interface AlertProps {
|
||||
variant?: AlertVariant
|
||||
title?: string
|
||||
children?: ReactNode
|
||||
dismissible?: boolean
|
||||
onDismiss?: () => void
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ICONS: Record<AlertVariant, string> = {
|
||||
info: 'ℹ',
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
error: '✕',
|
||||
}
|
||||
|
||||
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
||||
error: 'alert',
|
||||
warning: 'alert',
|
||||
info: 'status',
|
||||
success: 'status',
|
||||
}
|
||||
|
||||
export function Alert({
|
||||
variant = 'info',
|
||||
title,
|
||||
children,
|
||||
dismissible = false,
|
||||
onDismiss,
|
||||
icon,
|
||||
className,
|
||||
}: AlertProps) {
|
||||
const resolvedIcon = icon !== undefined ? icon : DEFAULT_ICONS[variant]
|
||||
const role = ARIA_ROLES[variant]
|
||||
|
||||
const classes = [styles.alert, styles[variant], className ?? '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div className={classes} role={role}>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{resolvedIcon}
|
||||
</span>
|
||||
|
||||
<div className={styles.content}>
|
||||
{title && <div className={styles.title}>{title}</div>}
|
||||
{children && <div className={styles.body}>{children}</div>}
|
||||
</div>
|
||||
|
||||
{dismissible && (
|
||||
<button
|
||||
className={styles.dismissBtn}
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss alert"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/design-system/primitives/FormField/FormField.module.css
Normal file
18
src/design-system/primitives/FormField/FormField.module.css
Normal 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;
|
||||
}
|
||||
88
src/design-system/primitives/FormField/FormField.test.tsx
Normal file
88
src/design-system/primitives/FormField/FormField.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
47
src/design-system/primitives/FormField/FormField.tsx
Normal file
47
src/design-system/primitives/FormField/FormField.tsx
Normal 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'
|
||||
11
src/design-system/primitives/Label/Label.module.css
Normal file
11
src/design-system/primitives/Label/Label.module.css
Normal 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;
|
||||
}
|
||||
34
src/design-system/primitives/Label/Label.test.tsx
Normal file
34
src/design-system/primitives/Label/Label.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
20
src/design-system/primitives/Label/Label.tsx
Normal file
20
src/design-system/primitives/Label/Label.tsx
Normal 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'
|
||||
@@ -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;
|
||||
}
|
||||
141
src/design-system/primitives/Pagination/Pagination.test.tsx
Normal file
141
src/design-system/primitives/Pagination/Pagination.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
113
src/design-system/primitives/Pagination/Pagination.tsx
Normal file
113
src/design-system/primitives/Pagination/Pagination.tsx
Normal 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"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
|
||||
{pageRange.map((item, idx) => {
|
||||
if (item === 'ellipsis') {
|
||||
return (
|
||||
<span key={`ellipsis-${idx}`} className={styles.ellipsis} aria-hidden="true">
|
||||
…
|
||||
</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"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
100
src/design-system/primitives/ProgressBar/ProgressBar.test.tsx
Normal file
100
src/design-system/primitives/ProgressBar/ProgressBar.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
60
src/design-system/primitives/ProgressBar/ProgressBar.tsx
Normal file
60
src/design-system/primitives/ProgressBar/ProgressBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/design-system/primitives/Radio/Radio.module.css
Normal file
92
src/design-system/primitives/Radio/Radio.module.css
Normal 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);
|
||||
}
|
||||
136
src/design-system/primitives/Radio/Radio.test.tsx
Normal file
136
src/design-system/primitives/Radio/Radio.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
128
src/design-system/primitives/Radio/Radio.tsx
Normal file
128
src/design-system/primitives/Radio/Radio.tsx
Normal 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
|
||||
}
|
||||
48
src/design-system/primitives/Skeleton/Skeleton.module.css
Normal file
48
src/design-system/primitives/Skeleton/Skeleton.module.css
Normal 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;
|
||||
}
|
||||
82
src/design-system/primitives/Skeleton/Skeleton.test.tsx
Normal file
82
src/design-system/primitives/Skeleton/Skeleton.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
71
src/design-system/primitives/Skeleton/Skeleton.tsx
Normal file
71
src/design-system/primitives/Skeleton/Skeleton.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
21
src/design-system/primitives/Textarea/Textarea.module.css
Normal file
21
src/design-system/primitives/Textarea/Textarea.module.css
Normal 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; }
|
||||
79
src/design-system/primitives/Textarea/Textarea.test.tsx
Normal file
79
src/design-system/primitives/Textarea/Textarea.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
31
src/design-system/primitives/Textarea/Textarea.tsx
Normal file
31
src/design-system/primitives/Textarea/Textarea.tsx
Normal 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'
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Alert } from './Alert/Alert'
|
||||
export { Avatar } from './Avatar/Avatar'
|
||||
export { Badge } from './Badge/Badge'
|
||||
export { Button } from './Button/Button'
|
||||
@@ -9,16 +10,23 @@ export { DateRangePicker } from './DateRangePicker/DateRangePicker'
|
||||
export { DateTimePicker } from './DateTimePicker/DateTimePicker'
|
||||
export { EmptyState } from './EmptyState/EmptyState'
|
||||
export { FilterPill } from './FilterPill/FilterPill'
|
||||
export { FormField } from './FormField/FormField'
|
||||
export { InfoCallout } from './InfoCallout/InfoCallout'
|
||||
export { Input } from './Input/Input'
|
||||
export { KeyboardHint } from './KeyboardHint/KeyboardHint'
|
||||
export { Label } from './Label/Label'
|
||||
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 { Select } from './Select/Select'
|
||||
export { Skeleton } from './Skeleton/Skeleton'
|
||||
export { Sparkline } from './Sparkline/Sparkline'
|
||||
export { Spinner } from './Spinner/Spinner'
|
||||
export { StatCard } from './StatCard/StatCard'
|
||||
export { StatusDot } from './StatusDot/StatusDot'
|
||||
export { Tag } from './Tag/Tag'
|
||||
export { Textarea } from './Textarea/Textarea'
|
||||
export { Toggle } from './Toggle/Toggle'
|
||||
export { Tooltip } from './Tooltip/Tooltip'
|
||||
|
||||
87
src/pages/Inventory/Inventory.module.css
Normal file
87
src/pages/Inventory/Inventory.module.css
Normal 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;
|
||||
}
|
||||
41
src/pages/Inventory/Inventory.tsx
Normal file
41
src/pages/Inventory/Inventory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
src/pages/Inventory/sections/CompositesSection.module.css
Normal file
74
src/pages/Inventory/sections/CompositesSection.module.css
Normal 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;
|
||||
}
|
||||
598
src/pages/Inventory/sections/CompositesSection.tsx
Normal file
598
src/pages/Inventory/sections/CompositesSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
src/pages/Inventory/sections/LayoutSection.module.css
Normal file
139
src/pages/Inventory/sections/LayoutSection.module.css
Normal 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;
|
||||
}
|
||||
140
src/pages/Inventory/sections/LayoutSection.tsx
Normal file
140
src/pages/Inventory/sections/LayoutSection.tsx
Normal 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}>
|
||||
<children> — 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>
|
||||
)
|
||||
}
|
||||
74
src/pages/Inventory/sections/PrimitivesSection.module.css
Normal file
74
src/pages/Inventory/sections/PrimitivesSection.module.css
Normal 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;
|
||||
}
|
||||
591
src/pages/Inventory/sections/PrimitivesSection.tsx
Normal file
591
src/pages/Inventory/sections/PrimitivesSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user