diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..18318de --- /dev/null +++ b/CLAUDE.md @@ -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 `` 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' +``` diff --git a/COMPONENT_GUIDE.md b/COMPONENT_GUIDE.md new file mode 100644 index 0000000..4424338 --- /dev/null +++ b/COMPONENT_GUIDE.md @@ -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 diff --git a/docs/superpowers/specs/2026-03-18-design-system-design.md b/docs/superpowers/specs/2026-03-18-design-system-design.md index 7dc10d9..a577694 100644 --- a/docs/superpowers/specs/2026-03-18-design-system-design.md +++ b/docs/superpowers/specs/2026-03-18-design-system-design.md @@ -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. diff --git a/src/App.tsx b/src/App.tsx index 4ac17ca..2107880 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> ) } diff --git a/src/design-system/composites/Accordion/Accordion.module.css b/src/design-system/composites/Accordion/Accordion.module.css new file mode 100644 index 0000000..e87507a --- /dev/null +++ b/src/design-system/composites/Accordion/Accordion.module.css @@ -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); +} diff --git a/src/design-system/composites/Accordion/Accordion.test.tsx b/src/design-system/composites/Accordion/Accordion.test.tsx new file mode 100644 index 0000000..9e24308 --- /dev/null +++ b/src/design-system/composites/Accordion/Accordion.test.tsx @@ -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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + expect(screen.getByText('Content A')).toBeVisible() + expect(screen.getByText('Content B')).toBeVisible() + }) + }) +}) diff --git a/src/design-system/composites/Accordion/Accordion.tsx b/src/design-system/composites/Accordion/Accordion.tsx new file mode 100644 index 0000000..510c8ee --- /dev/null +++ b/src/design-system/composites/Accordion/Accordion.tsx @@ -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>(() => { + const initial = new Set() + 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 ( +
+ {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 ( + handleToggle(item.id, open)} + className={itemClass} + > + {item.content} + + ) + })} +
+ ) +} diff --git a/src/design-system/composites/AlertDialog/AlertDialog.module.css b/src/design-system/composites/AlertDialog/AlertDialog.module.css new file mode 100644 index 0000000..2b55378 --- /dev/null +++ b/src/design-system/composites/AlertDialog/AlertDialog.module.css @@ -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; +} diff --git a/src/design-system/composites/AlertDialog/AlertDialog.test.tsx b/src/design-system/composites/AlertDialog/AlertDialog.test.tsx new file mode 100644 index 0000000..92d6881 --- /dev/null +++ b/src/design-system/composites/AlertDialog/AlertDialog.test.tsx @@ -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() + expect(screen.getByText('Delete item')).toBeInTheDocument() + expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument() + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByText('Delete item')).not.toBeInTheDocument() + expect(screen.queryByText('This action cannot be undone.')).not.toBeInTheDocument() + }) + + it('renders default button labels', () => { + render() + expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + it('renders custom button labels', () => { + render() + 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() + 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() + 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() + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalled() + }) + + it('disables both buttons when loading', () => { + render() + 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() + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus() + }) + }) + + it('renders danger variant icon', () => { + render() + // Icon area should be present (aria-hidden) + expect(screen.getByText('✕')).toBeInTheDocument() + }) + + it('renders warning variant icon', () => { + render() + expect(screen.getByText('⚠')).toBeInTheDocument() + }) + + it('renders info variant icon', () => { + render() + expect(screen.getByText('ℹ')).toBeInTheDocument() + }) +}) diff --git a/src/design-system/composites/AlertDialog/AlertDialog.tsx b/src/design-system/composites/AlertDialog/AlertDialog.tsx new file mode 100644 index 0000000..3b3335c --- /dev/null +++ b/src/design-system/composites/AlertDialog/AlertDialog.tsx @@ -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, 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(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 ( + +
+ + +

{title}

+

{description}

+ +
+ + + + +
+
+
+ ) +} diff --git a/src/design-system/composites/AvatarGroup/AvatarGroup.module.css b/src/design-system/composites/AvatarGroup/AvatarGroup.module.css new file mode 100644 index 0000000..fb7fc81 --- /dev/null +++ b/src/design-system/composites/AvatarGroup/AvatarGroup.module.css @@ -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; +} diff --git a/src/design-system/composites/AvatarGroup/AvatarGroup.test.tsx b/src/design-system/composites/AvatarGroup/AvatarGroup.test.tsx new file mode 100644 index 0000000..416d305 --- /dev/null +++ b/src/design-system/composites/AvatarGroup/AvatarGroup.test.tsx @@ -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) { + return render( + + + + ) +} + +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() + }) +}) diff --git a/src/design-system/composites/AvatarGroup/AvatarGroup.tsx b/src/design-system/composites/AvatarGroup/AvatarGroup.tsx new file mode 100644 index 0000000..c6349ad --- /dev/null +++ b/src/design-system/composites/AvatarGroup/AvatarGroup.tsx @@ -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 ( + + {visible.map((name, index) => ( + + ))} + {overflow > 0 && ( + + +{overflow} + + )} + + ) +} diff --git a/src/design-system/composites/Popover/Popover.module.css b/src/design-system/composites/Popover/Popover.module.css new file mode 100644 index 0000000..05e192a --- /dev/null +++ b/src/design-system/composites/Popover/Popover.module.css @@ -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; +} diff --git a/src/design-system/composites/Popover/Popover.test.tsx b/src/design-system/composites/Popover/Popover.test.tsx new file mode 100644 index 0000000..4b6f5f0 --- /dev/null +++ b/src/design-system/composites/Popover/Popover.test.tsx @@ -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( + Open} content={

Popover content

} />, + ) + expect(screen.queryByText('Popover content')).not.toBeInTheDocument() + }) + + it('shows content on trigger click', async () => { + const user = userEvent.setup() + render( + Open} content={

Popover content

} />, + ) + 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( + Open} content={

Popover content

} />, + ) + 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( + Open} content={

Popover content

} />, + ) + 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( +
+ Open} content={

Popover content

} /> + +
, + ) + 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( + Open} content={

Popover content

} />, + ) + 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( + Open} + content={

Top content

} + 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( + Open} + content={

Start aligned

} + 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( + Open} + content={} + />, + ) + await user.click(screen.getByText('Open')) + await user.click(screen.getByText('Inner button')) + expect(screen.getByText('Inner button')).toBeInTheDocument() + }) +}) diff --git a/src/design-system/composites/Popover/Popover.tsx b/src/design-system/composites/Popover/Popover.tsx new file mode 100644 index 0000000..c534861 --- /dev/null +++ b/src/design-system/composites/Popover/Popover.tsx @@ -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({ top: 0, left: 0 }) + const triggerRef = useRef(null) + const contentRef = useRef(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 ( +
+
setOpen((prev) => !prev)} + > + {trigger} +
+ {open && + createPortal( +
+ , + document.body, + )} +
+ ) +} diff --git a/src/design-system/composites/Toast/Toast.module.css b/src/design-system/composites/Toast/Toast.module.css new file mode 100644 index 0000000..df4735f --- /dev/null +++ b/src/design-system/composites/Toast/Toast.module.css @@ -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%); + } +} diff --git a/src/design-system/composites/Toast/Toast.test.tsx b/src/design-system/composites/Toast/Toast.test.tsx new file mode 100644 index 0000000..430dca9 --- /dev/null +++ b/src/design-system/composites/Toast/Toast.test.tsx @@ -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 + + function Consumer() { + api = useToast() + return null + } + + render( + + + , + ) + + 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()).toThrow('useToast must be used within a ToastProvider') + consoleSpy.mockRestore() + }) +}) diff --git a/src/design-system/composites/Toast/Toast.tsx b/src/design-system/composites/Toast/Toast.tsx new file mode 100644 index 0000000..c5220a3 --- /dev/null +++ b/src/design-system/composites/Toast/Toast.tsx @@ -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> { + 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 = { + info: 'ℹ', + success: '✓', + warning: '⚠', + error: '✕', +} + +// ── Context ──────────────────────────────────────────────────────────────── + +const ToastContext = createContext(null) + +// ── ToastProvider ────────────────────────────────────────────────────────── + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const timersRef = useRef>>(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 ( + + {children} + + + ) +} + +// ── 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( +
+ {toasts.map((t) => ( + + ))} +
, + document.body, + ) +} + +// ── ToastItem ────────────────────────────────────────────────────────────── + +interface ToastItemComponentProps { + toast: ToastItem + onDismiss: (id: string) => void +} + +function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) { + return ( +
+ +
+
{toast.title}
+ {toast.description && ( +
{toast.description}
+ )} +
+ +
+ ) +} diff --git a/src/design-system/composites/TreeView/TreeView.module.css b/src/design-system/composites/TreeView/TreeView.module.css new file mode 100644 index 0000000..bbfa6b4 --- /dev/null +++ b/src/design-system/composites/TreeView/TreeView.module.css @@ -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; +} diff --git a/src/design-system/composites/TreeView/TreeView.test.tsx b/src/design-system/composites/TreeView/TreeView.test.tsx new file mode 100644 index 0000000..f7e31ec --- /dev/null +++ b/src/design-system/composites/TreeView/TreeView.test.tsx @@ -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: }, + ], + }, + { + id: 'root2', + label: 'Root Two', + }, +] + +describe('TreeView', () => { + describe('rendering', () => { + it('renders top-level nodes', () => { + render() + expect(screen.getByText('Root One')).toBeInTheDocument() + expect(screen.getByText('Root Two')).toBeInTheDocument() + }) + + it('does not render children of collapsed nodes', () => { + render() + expect(screen.queryByText('Child One')).not.toBeInTheDocument() + expect(screen.queryByText('Child Two')).not.toBeInTheDocument() + }) + + it('renders icon when provided', () => { + render( {}} />) + expect(screen.getByText('★')).toBeInTheDocument() + }) + + it('renders meta text when provided', () => { + render( {}} />) + expect(screen.getByText('meta-a')).toBeInTheDocument() + }) + + it('shows chevron on parent nodes', () => { + render() + // 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() + // 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() + expect(screen.getByRole('tree')).toBeInTheDocument() + }) + + it('has role="treeitem" on each node row', () => { + render() + const items = screen.getAllByRole('treeitem') + expect(items.length).toBeGreaterThanOrEqual(2) + }) + + it('sets aria-expanded="false" on collapsed parent nodes', () => { + render() + 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( {}} />) + 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() + const treeitem = screen.getByRole('treeitem', { name: /Root Two/i }) + expect(treeitem).not.toHaveAttribute('aria-expanded') + }) + + it('sets aria-selected on selected node', () => { + render() + 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() + 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() + 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() + 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( + {}} />, + ) + 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() + 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() + 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() + 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() + await user.click(screen.getByRole('treeitem', { name: /Root One/i })) + expect(onSelect).toHaveBeenCalledWith('root1') + }) + + it('marks the selected node visually', () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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( + {}} + />, + ) + expect(screen.getByText('Grandchild One')).toBeInTheDocument() + expect(screen.getByText('Grandchild Two')).toBeInTheDocument() + }) + + it('does not render grandchildren when parent is collapsed', () => { + render( + {}} + />, + ) + expect(screen.queryByText('Grandchild One')).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/design-system/composites/TreeView/TreeView.tsx b/src/design-system/composites/TreeView/TreeView.tsx new file mode 100644 index 0000000..f16d2fa --- /dev/null +++ b/src/design-system/composites/TreeView/TreeView.tsx @@ -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, + 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>(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(null) + const treeRef = useRef(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) => { + 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 ( +
    + {nodes.map((node) => ( + + ))} +
+ ) +} + +interface TreeNodeRowProps { + node: TreeNode + depth: number + expandedSet: Set + 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 ( +
  • +
    onFocus(node.id)} + > + + {hasChildren ? ( + + ) : null} + + {node.icon && ( + + )} + {node.label} + {node.meta && ( + {node.meta} + )} +
    + {hasChildren && isExpanded && ( +
      + {node.children!.map((child) => ( + + ))} +
    + )} +
  • + ) +} diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index 427f40d..cd07917 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -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' diff --git a/src/design-system/primitives/Alert/Alert.module.css b/src/design-system/primitives/Alert/Alert.module.css new file mode 100644 index 0000000..0fcaff1 --- /dev/null +++ b/src/design-system/primitives/Alert/Alert.module.css @@ -0,0 +1,93 @@ +.alert { + display: flex; + align-items: flex-start; + gap: 10px; + width: 100%; + border-radius: var(--radius-md); + padding: 12px 16px; + border-left: 4px solid transparent; + box-sizing: border-box; + font-family: var(--font-body); + position: relative; +} + +/* Variants */ +.info { + background: var(--running-bg); + border-left-color: var(--running); +} + +.success { + background: var(--success-bg); + border-left-color: var(--success); +} + +.warning { + background: var(--warning-bg); + border-left-color: var(--warning); +} + +.error { + background: var(--error-bg); + border-left-color: var(--error); +} + +/* Icon */ +.icon { + flex-shrink: 0; + font-size: 14px; + line-height: 1.4; +} + +.info .icon { color: var(--running); } +.success .icon { color: var(--success); } +.warning .icon { color: var(--warning); } +.error .icon { color: var(--error); } + +/* Content */ +.content { + flex: 1; + min-width: 0; +} + +.title { + font-size: 13px; + font-weight: 600; + line-height: 1.4; + margin-bottom: 2px; +} + +.info .title { color: var(--running); } +.success .title { color: var(--success); } +.warning .title { color: var(--warning); } +.error .title { color: var(--error); } + +.body { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Dismiss button */ +.dismissBtn { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + padding: 0; + line-height: 1; + margin-left: auto; +} + +.dismissBtn:hover { + background: var(--border); + color: var(--text-primary, var(--text-secondary)); +} diff --git a/src/design-system/primitives/Alert/Alert.test.tsx b/src/design-system/primitives/Alert/Alert.test.tsx new file mode 100644 index 0000000..50d72a1 --- /dev/null +++ b/src/design-system/primitives/Alert/Alert.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Alert } from './Alert' + +describe('Alert', () => { + it('renders children', () => { + render(Something went wrong) + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('renders title when provided', () => { + render(Body text) + expect(screen.getByText('Heads up')).toBeInTheDocument() + expect(screen.getByText('Body text')).toBeInTheDocument() + }) + + it('renders without title', () => { + render(Just a message) + expect(screen.getByText('Just a message')).toBeInTheDocument() + }) + + it('uses role="alert" for error variant', () => { + render(Error message) + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + it('uses role="alert" for warning variant', () => { + render(Warning message) + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + it('uses role="status" for info variant', () => { + render(Info message) + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('uses role="status" for success variant', () => { + render(Success message) + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('defaults to info variant (role="status")', () => { + render(Default alert) + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('shows default icon for each variant', () => { + const { rerender } = render(msg) + expect(screen.getByText('ℹ')).toBeInTheDocument() + + rerender(msg) + expect(screen.getByText('✓')).toBeInTheDocument() + + rerender(msg) + expect(screen.getByText('⚠')).toBeInTheDocument() + + rerender(msg) + expect(screen.getByText('✕')).toBeInTheDocument() + }) + + it('renders a custom icon when provided', () => { + render(★}>Custom icon alert) + expect(screen.getByText('★')).toBeInTheDocument() + // Default icon should not appear + expect(screen.queryByText('ℹ')).not.toBeInTheDocument() + }) + + it('does not show dismiss button when dismissible is false', () => { + render(Non-dismissible) + expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument() + }) + + it('shows dismiss button when dismissible is true', () => { + render(Dismissible alert) + expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument() + }) + + it('calls onDismiss when dismiss button is clicked', async () => { + const onDismiss = vi.fn() + render( + + Dismissible alert + , + ) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /dismiss/i })) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('applies a custom className', () => { + const { container } = render(Alert) + expect(container.firstChild).toHaveClass('my-custom-class') + }) + + it('applies the correct variant class', () => { + const { container } = render(Error) + expect(container.firstChild).toHaveClass('error') + }) +}) diff --git a/src/design-system/primitives/Alert/Alert.tsx b/src/design-system/primitives/Alert/Alert.tsx new file mode 100644 index 0000000..e6b637f --- /dev/null +++ b/src/design-system/primitives/Alert/Alert.tsx @@ -0,0 +1,69 @@ +import { ReactNode } from 'react' +import styles from './Alert.module.css' + +type AlertVariant = 'info' | 'success' | 'warning' | 'error' + +interface AlertProps { + variant?: AlertVariant + title?: string + children?: ReactNode + dismissible?: boolean + onDismiss?: () => void + icon?: ReactNode + className?: string +} + +const DEFAULT_ICONS: Record = { + info: 'ℹ', + success: '✓', + warning: '⚠', + error: '✕', +} + +const ARIA_ROLES: Record = { + error: 'alert', + warning: 'alert', + info: 'status', + success: 'status', +} + +export function Alert({ + variant = 'info', + title, + children, + dismissible = false, + onDismiss, + icon, + className, +}: AlertProps) { + const resolvedIcon = icon !== undefined ? icon : DEFAULT_ICONS[variant] + const role = ARIA_ROLES[variant] + + const classes = [styles.alert, styles[variant], className ?? ''] + .filter(Boolean) + .join(' ') + + return ( +
    + + +
    + {title &&
    {title}
    } + {children &&
    {children}
    } +
    + + {dismissible && ( + + )} +
    + ) +} diff --git a/src/design-system/primitives/FormField/FormField.module.css b/src/design-system/primitives/FormField/FormField.module.css new file mode 100644 index 0000000..b0524e9 --- /dev/null +++ b/src/design-system/primitives/FormField/FormField.module.css @@ -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; +} diff --git a/src/design-system/primitives/FormField/FormField.test.tsx b/src/design-system/primitives/FormField/FormField.test.tsx new file mode 100644 index 0000000..333b468 --- /dev/null +++ b/src/design-system/primitives/FormField/FormField.test.tsx @@ -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( + + + , + ) + expect(screen.getByText('Username')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('renders hint text when no error', () => { + render( + + + , + ) + expect(screen.getByText('We will never share your email')).toBeInTheDocument() + }) + + it('renders error instead of hint when error is provided', () => { + render( + + + , + ) + 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( + + + , + ) + expect(screen.getByText('*')).toBeInTheDocument() + }) + + it('adds error class to wrapper when error is present', () => { + const { container } = render( + + + , + ) + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toMatch(/error/) + }) + + it('does not add error class when no error', () => { + const { container } = render( + + + , + ) + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).not.toMatch(/error/) + }) + + it('renders children without label when label prop is omitted', () => { + render( + + + , + ) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.queryByRole('label')).not.toBeInTheDocument() + }) + + it('associates label with input via htmlFor', () => { + render( + + + , + ) + const label = screen.getByText('Search').closest('label') + expect(label).toHaveAttribute('for', 'search-input') + }) +}) diff --git a/src/design-system/primitives/FormField/FormField.tsx b/src/design-system/primitives/FormField/FormField.tsx new file mode 100644 index 0000000..84d3893 --- /dev/null +++ b/src/design-system/primitives/FormField/FormField.tsx @@ -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 ( +
    + {label && ( + + )} + {children} + {error ? ( + + {error} + + ) : hint ? ( + {hint} + ) : null} +
    + ) +} + +FormField.displayName = 'FormField' diff --git a/src/design-system/primitives/Label/Label.module.css b/src/design-system/primitives/Label/Label.module.css new file mode 100644 index 0000000..b542bf7 --- /dev/null +++ b/src/design-system/primitives/Label/Label.module.css @@ -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; +} diff --git a/src/design-system/primitives/Label/Label.test.tsx b/src/design-system/primitives/Label/Label.test.tsx new file mode 100644 index 0000000..afa2fb0 --- /dev/null +++ b/src/design-system/primitives/Label/Label.test.tsx @@ -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() + expect(screen.getByText('Email address')).toBeInTheDocument() + }) + + it('does not show asterisk when required is not set', () => { + render() + expect(screen.queryByText('*')).not.toBeInTheDocument() + }) + + it('shows asterisk when required', () => { + render() + expect(screen.getByText('*')).toBeInTheDocument() + }) + + it('passes htmlFor to the label element', () => { + render() + const label = screen.getByText('Email') + expect(label).toHaveAttribute('for', 'email-input') + }) + + it('forwards ref to the label element', () => { + let ref: HTMLLabelElement | null = null + render( + + ) + expect(ref).toBeInstanceOf(HTMLLabelElement) + }) +}) diff --git a/src/design-system/primitives/Label/Label.tsx b/src/design-system/primitives/Label/Label.tsx new file mode 100644 index 0000000..d343c3f --- /dev/null +++ b/src/design-system/primitives/Label/Label.tsx @@ -0,0 +1,20 @@ +import styles from './Label.module.css' +import { forwardRef, type LabelHTMLAttributes, type ReactNode } from 'react' + +interface LabelProps extends LabelHTMLAttributes { + required?: boolean + children?: ReactNode + className?: string +} + +export const Label = forwardRef( + ({ required, children, className, ...rest }, ref) => { + return ( + + ) + }, +) +Label.displayName = 'Label' diff --git a/src/design-system/primitives/Pagination/Pagination.module.css b/src/design-system/primitives/Pagination/Pagination.module.css new file mode 100644 index 0000000..4b19ba5 --- /dev/null +++ b/src/design-system/primitives/Pagination/Pagination.module.css @@ -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; +} diff --git a/src/design-system/primitives/Pagination/Pagination.test.tsx b/src/design-system/primitives/Pagination/Pagination.test.tsx new file mode 100644 index 0000000..826c206 --- /dev/null +++ b/src/design-system/primitives/Pagination/Pagination.test.tsx @@ -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() + 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() + const activeBtn = screen.getByRole('button', { name: 'Page 5' }) + expect(activeBtn).toHaveAttribute('aria-current', 'page') + }) + + it('shows ellipsis when pages are far apart', () => { + render() + const ellipses = screen.getAllByText('…') + expect(ellipses.length).toBeGreaterThanOrEqual(1) + }) + + it('disables prev button on first page', () => { + render() + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled() + }) + + it('does not disable next button on first page', () => { + render() + expect(screen.getByRole('button', { name: 'Next page' })).not.toBeDisabled() + }) + + it('disables next button on last page', () => { + render() + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled() + }) + + it('does not disable prev button on last page', () => { + render() + 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() + 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() + 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() + await user.click(screen.getByRole('button', { name: 'Page 6' })) + expect(onPageChange).toHaveBeenCalledWith(6) + }) + + it('applies custom className', () => { + const { container } = render( + + ) + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('renders with siblingCount=0 showing only current page between first and last', () => { + render() + expect(screen.getByRole('button', { name: 'Page 10' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Page 1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Page 20' })).toBeInTheDocument() + }) +}) diff --git a/src/design-system/primitives/Pagination/Pagination.tsx b/src/design-system/primitives/Pagination/Pagination.tsx new file mode 100644 index 0000000..9bcf75a --- /dev/null +++ b/src/design-system/primitives/Pagination/Pagination.tsx @@ -0,0 +1,113 @@ +import styles from './Pagination.module.css' +import type { HTMLAttributes } from 'react' + +interface PaginationProps extends HTMLAttributes { + 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 ( + + ) +} diff --git a/src/design-system/primitives/ProgressBar/ProgressBar.module.css b/src/design-system/primitives/ProgressBar/ProgressBar.module.css new file mode 100644 index 0000000..31b5c24 --- /dev/null +++ b/src/design-system/primitives/ProgressBar/ProgressBar.module.css @@ -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; +} diff --git a/src/design-system/primitives/ProgressBar/ProgressBar.test.tsx b/src/design-system/primitives/ProgressBar/ProgressBar.test.tsx new file mode 100644 index 0000000..c701d00 --- /dev/null +++ b/src/design-system/primitives/ProgressBar/ProgressBar.test.tsx @@ -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() + const bar = screen.getByRole('progressbar') + expect(bar).toBeInTheDocument() + }) + + it('sets aria-valuenow to the clamped value', () => { + render() + const bar = screen.getByRole('progressbar') + expect(bar).toHaveAttribute('aria-valuenow', '42') + }) + + it('always sets aria-valuemin=0 and aria-valuemax=100', () => { + render() + 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() + const bar = screen.getByRole('progressbar') + expect(bar).toHaveAttribute('aria-valuenow', '100') + }) + + it('clamps value below 0 to 0', () => { + render() + const bar = screen.getByRole('progressbar') + expect(bar).toHaveAttribute('aria-valuenow', '0') + }) + + it('omits aria-valuenow when indeterminate', () => { + render() + const bar = screen.getByRole('progressbar') + expect(bar).not.toHaveAttribute('aria-valuenow') + }) + + it('renders label text above the bar', () => { + render() + expect(screen.getByText('Upload progress')).toBeInTheDocument() + }) + + it('does not render label element when label is omitted', () => { + render() + expect(screen.queryByText('Upload progress')).not.toBeInTheDocument() + }) + + it('applies variant class to fill', () => { + const { container } = render() + const fill = container.querySelector('.fill') + expect(fill).toHaveClass('success') + }) + + it('applies error variant class to fill', () => { + const { container } = render() + const fill = container.querySelector('.fill') + expect(fill).toHaveClass('error') + }) + + it('applies sm size class to track', () => { + render() + const track = screen.getByRole('progressbar') + expect(track).toHaveClass('sm') + }) + + it('applies md size class to track by default', () => { + render() + const track = screen.getByRole('progressbar') + expect(track).toHaveClass('md') + }) + + it('applies indeterminate class to fill when indeterminate', () => { + const { container } = render() + const fill = container.querySelector('.fill') + expect(fill).toHaveClass('indeterminate') + }) + + it('sets fill width style matching value', () => { + const { container } = render() + const fill = container.querySelector('.fill') as HTMLElement + expect(fill.style.width).toBe('75%') + }) + + it('does not set width style when indeterminate', () => { + const { container } = render() + const fill = container.querySelector('.fill') as HTMLElement + expect(fill.style.width).toBe('') + }) + + it('passes through className to the track', () => { + render() + const bar = screen.getByRole('progressbar') + expect(bar).toHaveClass('custom-class') + }) +}) diff --git a/src/design-system/primitives/ProgressBar/ProgressBar.tsx b/src/design-system/primitives/ProgressBar/ProgressBar.tsx new file mode 100644 index 0000000..c2a6e9f --- /dev/null +++ b/src/design-system/primitives/ProgressBar/ProgressBar.tsx @@ -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 ( +
    + {label && ( + {label} + )} +
    +
    +
    +
    + ) +} diff --git a/src/design-system/primitives/Radio/Radio.module.css b/src/design-system/primitives/Radio/Radio.module.css new file mode 100644 index 0000000..6be91aa --- /dev/null +++ b/src/design-system/primitives/Radio/Radio.module.css @@ -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); +} diff --git a/src/design-system/primitives/Radio/Radio.test.tsx b/src/design-system/primitives/Radio/Radio.test.tsx new file mode 100644 index 0000000..2abd697 --- /dev/null +++ b/src/design-system/primitives/Radio/Radio.test.tsx @@ -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( + + + + + , + ) + expect(screen.getByLabelText('Red')).toBeInTheDocument() + expect(screen.getByLabelText('Blue')).toBeInTheDocument() + expect(screen.getByLabelText('Green')).toBeInTheDocument() + }) + + it('marks the current value as checked', () => { + render( + + + + , + ) + 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( + + + + , + ) + 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( + + + + , + ) + await user.click(screen.getByLabelText('Blue')) + expect(onChange).not.toHaveBeenCalled() + }) + + it('disabled item has disabled attribute', () => { + render( + + + + , + ) + expect(screen.getByLabelText('Blue')).toBeDisabled() + expect(screen.getByLabelText('Red')).not.toBeDisabled() + }) + + it('all inputs share the same name', () => { + render( + + + + + , + ) + 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( + + + + , + ) + 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( + + + + , + ) + const group = container.firstChild as HTMLElement + expect(group.className).toMatch(/horizontal/) + }) + + it('applies vertical layout class by default', () => { + const { container } = render( + + + , + ) + const group = container.firstChild as HTMLElement + expect(group.className).toMatch(/vertical/) + }) + + it('accepts a ReactNode as label', () => { + render( + + Bold Red} /> + , + ) + expect(screen.getByText('Bold Red')).toBeInTheDocument() + }) + + it('applies custom className to RadioGroup', () => { + const { container } = render( + + + , + ) + const group = container.firstChild as HTMLElement + expect(group.className).toContain('custom-class') + }) +}) diff --git a/src/design-system/primitives/Radio/Radio.tsx b/src/design-system/primitives/Radio/Radio.tsx new file mode 100644 index 0000000..ab53f93 --- /dev/null +++ b/src/design-system/primitives/Radio/Radio.tsx @@ -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(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 ( + +
    + {children} +
    +
    + ) +} + +// ── 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) { + // 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 ( + + ) +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function getGroupInputs(current: HTMLInputElement): HTMLInputElement[] { + const group = current.closest('[role="radiogroup"]') + if (!group) return [] + return Array.from(group.querySelectorAll('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 +} diff --git a/src/design-system/primitives/Skeleton/Skeleton.module.css b/src/design-system/primitives/Skeleton/Skeleton.module.css new file mode 100644 index 0000000..d801a0c --- /dev/null +++ b/src/design-system/primitives/Skeleton/Skeleton.module.css @@ -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; +} diff --git a/src/design-system/primitives/Skeleton/Skeleton.test.tsx b/src/design-system/primitives/Skeleton/Skeleton.test.tsx new file mode 100644 index 0000000..bc5177d --- /dev/null +++ b/src/design-system/primitives/Skeleton/Skeleton.test.tsx @@ -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() + const el = container.firstChild as HTMLElement + expect(el).toHaveClass('rectangular') + expect(el).toHaveClass('shimmer') + }) + + it('renders text variant with single bar', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el).toHaveClass('text') + expect(el).toHaveClass('shimmer') + }) + + it('renders circular variant', () => { + const { container } = render() + 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() + 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() + 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() + 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() + 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() + const el = container.firstChild as HTMLElement + expect(el).toHaveClass('custom-class') + }) + + it('circular has default 40x40 dimensions', () => { + const { container } = render() + 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() + 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() + const el = container.firstChild as HTMLElement + expect(el).toHaveAttribute('aria-hidden', 'true') + }) +}) diff --git a/src/design-system/primitives/Skeleton/Skeleton.tsx b/src/design-system/primitives/Skeleton/Skeleton.tsx new file mode 100644 index 0000000..75e3532 --- /dev/null +++ b/src/design-system/primitives/Skeleton/Skeleton.tsx @@ -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 ( +