feat: promote mock UI patterns into design system
New components: KpiStrip, SplitPane, EntityList, LogViewer, StatusText Extended: Card (title prop) Refactored: AgentHealth to use DataTable Updated: COMPONENT_GUIDE.md, Inventory demos Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@
|
|||||||
|
|
||||||
### "I need to show status"
|
### "I need to show status"
|
||||||
- Dot indicator → **StatusDot** (live, stale, dead, success, warning, error, running)
|
- Dot indicator → **StatusDot** (live, stale, dead, success, warning, error, running)
|
||||||
|
- Inline colored status value → **StatusText** (success, warning, error, running, muted — with optional bold)
|
||||||
- Labeled status → **Badge** with semantic color
|
- Labeled status → **Badge** with semantic color
|
||||||
- Removable label → **Tag**
|
- Removable label → **Tag**
|
||||||
|
|
||||||
@@ -57,6 +58,9 @@
|
|||||||
- Event log → **EventFeed**
|
- Event log → **EventFeed**
|
||||||
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
- Processing pipeline (Gantt view) → **ProcessorTimeline**
|
||||||
- Processing pipeline (flow diagram) → **RouteFlow**
|
- Processing pipeline (flow diagram) → **RouteFlow**
|
||||||
|
- Row of summary KPIs → **KpiStrip** (horizontal strip with colored borders, trends, sparklines)
|
||||||
|
- Scrollable log output → **LogViewer** (timestamped, severity-colored monospace entries)
|
||||||
|
- Searchable, selectable entity list → **EntityList** (search header, selection highlighting, pairs with SplitPane)
|
||||||
|
|
||||||
### "I need to organize content"
|
### "I need to organize content"
|
||||||
- Collapsible sections (standalone) → **Collapsible**
|
- Collapsible sections (standalone) → **Collapsible**
|
||||||
@@ -64,15 +68,17 @@
|
|||||||
- Tabbed content → **Tabs**
|
- Tabbed content → **Tabs**
|
||||||
- Tab switching with pill/segment style → **SegmentedTabs**
|
- Tab switching with pill/segment style → **SegmentedTabs**
|
||||||
- Side panel inspector → **DetailPanel**
|
- Side panel inspector → **DetailPanel**
|
||||||
|
- Master/detail split layout → **SplitPane** (list on left, detail on right, configurable ratio)
|
||||||
- Section with title + action → **SectionHeader**
|
- Section with title + action → **SectionHeader**
|
||||||
- Empty content placeholder → **EmptyState**
|
- Empty content placeholder → **EmptyState**
|
||||||
- Grouped content box → **Card** (with optional accent)
|
- Grouped content box → **Card** (with optional accent and title)
|
||||||
- Grouped items with header + meta + footer → **GroupCard** (e.g., app instances)
|
- Grouped items with header + meta + footer → **GroupCard** (e.g., app instances)
|
||||||
|
|
||||||
### "I need to display text"
|
### "I need to display text"
|
||||||
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
|
- Code/JSON payload → **CodeBlock** (with line numbers, copy button)
|
||||||
- Monospace inline text → **MonoText**
|
- Monospace inline text → **MonoText**
|
||||||
- Keyboard shortcut hint → **KeyboardHint**
|
- Keyboard shortcut hint → **KeyboardHint**
|
||||||
|
- Colored inline status text → **StatusText** (semantic color + optional bold, see also "I need to show status")
|
||||||
|
|
||||||
### "I need to show people/users"
|
### "I need to show people/users"
|
||||||
- Single user avatar → **Avatar**
|
- Single user avatar → **Avatar**
|
||||||
@@ -115,6 +121,13 @@ Row of StatCard components (each with optional Sparkline and trend)
|
|||||||
Below: charts (AreaChart, LineChart, BarChart)
|
Below: charts (AreaChart, LineChart, BarChart)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Master/detail management pattern
|
||||||
|
```
|
||||||
|
SplitPane + EntityList for CRUD list/detail screens (users, groups, roles)
|
||||||
|
EntityList provides: search header, add button, selectable list
|
||||||
|
SplitPane provides: responsive two-column layout with empty state
|
||||||
|
```
|
||||||
|
|
||||||
### Detail/inspector pattern
|
### Detail/inspector pattern
|
||||||
```
|
```
|
||||||
DetailPanel (right slide) with Tabs for sections OR children for scrollable content
|
DetailPanel (right slide) with Tabs for sections OR children for scrollable content
|
||||||
@@ -162,7 +175,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| Breadcrumb | composite | Navigation path showing current location |
|
| Breadcrumb | composite | Navigation path showing current location |
|
||||||
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
||||||
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
||||||
| Card | primitive | Content container with optional accent border |
|
| Card | primitive | Content container with optional accent border and title header |
|
||||||
| Checkbox | primitive | Boolean input with label |
|
| Checkbox | primitive | Boolean input with label |
|
||||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||||
| Collapsible | primitive | Single expand/collapse section |
|
| Collapsible | primitive | Single expand/collapse section |
|
||||||
@@ -174,6 +187,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
|
| DetailPanel | composite | Slide-in side panel with tabs or children for scrollable content |
|
||||||
| Dropdown | composite | Action menu triggered by any element |
|
| Dropdown | composite | Action menu triggered by any element |
|
||||||
| EmptyState | primitive | Placeholder for empty content areas |
|
| EmptyState | primitive | Placeholder for empty content areas |
|
||||||
|
| EntityList | composite | Searchable, selectable entity list with add button. Pair with SplitPane for CRUD management screens |
|
||||||
| EventFeed | composite | Chronological event log with severity |
|
| EventFeed | composite | Chronological event log with severity |
|
||||||
| FilterBar | composite | Search + filter controls for data views |
|
| FilterBar | composite | Search + filter controls for data views |
|
||||||
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
|
| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. |
|
||||||
@@ -183,8 +197,10 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
|
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
|
||||||
| Input | primitive | Single-line text input with optional icon |
|
| Input | primitive | Single-line text input with optional icon |
|
||||||
| KeyboardHint | primitive | Keyboard shortcut display |
|
| KeyboardHint | primitive | Keyboard shortcut display |
|
||||||
|
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
|
||||||
| Label | primitive | Form label with optional required asterisk |
|
| Label | primitive | Form label with optional required asterisk |
|
||||||
| LineChart | composite | Time series line visualization |
|
| LineChart | composite | Time series line visualization |
|
||||||
|
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
|
||||||
| MenuItem | composite | Sidebar navigation item with health/count |
|
| MenuItem | composite | Sidebar navigation item with health/count |
|
||||||
| Modal | composite | Generic dialog overlay with backdrop |
|
| Modal | composite | Generic dialog overlay with backdrop |
|
||||||
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
|
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
|
||||||
@@ -202,9 +218,11 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
||||||
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
||||||
| Sparkline | primitive | Inline mini chart for trends |
|
| Sparkline | primitive | Inline mini chart for trends |
|
||||||
|
| SplitPane | composite | Two-column master/detail layout with configurable ratio and empty state |
|
||||||
| Spinner | primitive | Animated loading indicator |
|
| Spinner | primitive | Animated loading indicator |
|
||||||
| StatCard | primitive | KPI card with value, trend, optional sparkline |
|
| StatCard | primitive | KPI card with value, trend, optional sparkline |
|
||||||
| StatusDot | primitive | Colored dot for status indication |
|
| StatusDot | primitive | Colored dot for status indication |
|
||||||
|
| StatusText | primitive | Inline colored status span (success, warning, error, running, muted) with optional bold |
|
||||||
| Tabs | composite | Tabbed content switcher with optional counts |
|
| Tabs | composite | Tabbed content switcher with optional counts |
|
||||||
| Tag | primitive | Removable colored label |
|
| Tag | primitive | Removable colored label |
|
||||||
| Textarea | primitive | Multi-line text input with resize control |
|
| Textarea | primitive | Multi-line text input with resize control |
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.entityListRoot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeaderSearch {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItem:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityItemSelected {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
border-left: 3px solid var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyMessage {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
167
src/design-system/composites/EntityList/EntityList.test.tsx
Normal file
167
src/design-system/composites/EntityList/EntityList.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { EntityList } from './EntityList'
|
||||||
|
|
||||||
|
interface TestItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: TestItem[] = [
|
||||||
|
{ id: '1', name: 'Alpha' },
|
||||||
|
{ id: '2', name: 'Beta' },
|
||||||
|
{ id: '3', name: 'Gamma' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('EntityList', () => {
|
||||||
|
it('renders all items', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Gamma')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSelect when item clicked', async () => {
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
await user.click(screen.getByText('Beta'))
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights selected item (aria-selected="true" and has selected class)', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
selectedId="2"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const selectedOption = screen.getByText('Beta').closest('[role="option"]')
|
||||||
|
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||||
|
|
||||||
|
const unselectedOption = screen.getByText('Alpha').closest('[role="option"]')
|
||||||
|
expect(unselectedOption).toHaveAttribute('aria-selected', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders search input when onSearch provided', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
onSearch={() => {}}
|
||||||
|
searchPlaceholder="Filter items..."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByPlaceholderText('Filter items...')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSearch when typing in search', async () => {
|
||||||
|
const onSearch = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
onSearch={onSearch}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const input = screen.getByPlaceholderText('Search...')
|
||||||
|
await user.type(input, 'test')
|
||||||
|
expect(onSearch).toHaveBeenLastCalledWith('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders add button when onAdd provided', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
onAdd={() => {}}
|
||||||
|
addLabel="Add Item"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Add Item')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAdd when add button clicked', async () => {
|
||||||
|
const onAdd = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
onAdd={onAdd}
|
||||||
|
addLabel="Add Item"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
await user.click(screen.getByText('Add Item'))
|
||||||
|
expect(onAdd).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides header when no search or add', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
// No input or button should be present in the header area
|
||||||
|
expect(container.querySelector('input')).toBeNull()
|
||||||
|
expect(container.querySelector('button')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty message when items is empty', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={[]}
|
||||||
|
renderItem={(item: TestItem) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item: TestItem) => item.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('No items found')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows custom empty message', () => {
|
||||||
|
render(
|
||||||
|
<EntityList
|
||||||
|
items={[]}
|
||||||
|
renderItem={(item: TestItem) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item: TestItem) => item.id}
|
||||||
|
emptyMessage="Nothing here"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Nothing here')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EntityList
|
||||||
|
items={items}
|
||||||
|
renderItem={(item) => <span>{item.name}</span>}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
className="custom-class"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class')
|
||||||
|
})
|
||||||
|
})
|
||||||
97
src/design-system/composites/EntityList/EntityList.tsx
Normal file
97
src/design-system/composites/EntityList/EntityList.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { Input } from '../../primitives/Input/Input'
|
||||||
|
import { Button } from '../../primitives/Button/Button'
|
||||||
|
import styles from './EntityList.module.css'
|
||||||
|
|
||||||
|
interface EntityListProps<T> {
|
||||||
|
items: T[]
|
||||||
|
renderItem: (item: T, isSelected: boolean) => ReactNode
|
||||||
|
getItemId: (item: T) => string
|
||||||
|
selectedId?: string
|
||||||
|
onSelect?: (id: string) => void
|
||||||
|
searchPlaceholder?: string
|
||||||
|
onSearch?: (query: string) => void
|
||||||
|
addLabel?: string
|
||||||
|
onAdd?: () => void
|
||||||
|
emptyMessage?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityList<T>({
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
getItemId,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
searchPlaceholder = 'Search...',
|
||||||
|
onSearch,
|
||||||
|
addLabel,
|
||||||
|
onAdd,
|
||||||
|
emptyMessage = 'No items found',
|
||||||
|
className,
|
||||||
|
}: EntityListProps<T>) {
|
||||||
|
const [searchValue, setSearchValue] = useState('')
|
||||||
|
const showHeader = !!onSearch || !!onAdd
|
||||||
|
|
||||||
|
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const value = e.target.value
|
||||||
|
setSearchValue(value)
|
||||||
|
onSearch?.(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchClear() {
|
||||||
|
setSearchValue('')
|
||||||
|
onSearch?.('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
|
||||||
|
{showHeader && (
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
{onSearch && (
|
||||||
|
<Input
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchValue}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onClear={handleSearchClear}
|
||||||
|
className={styles.listHeaderSearch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onAdd && addLabel && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={onAdd}>
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.list} role="listbox">
|
||||||
|
{items.map((item) => {
|
||||||
|
const id = getItemId(item)
|
||||||
|
const isSelected = id === selectedId
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||||
|
onClick={() => onSelect?.(id)}
|
||||||
|
role="option"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelect?.(id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderItem(item, isSelected)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className={styles.emptyMessage}>{emptyMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
src/design-system/composites/KpiStrip/KpiStrip.module.css
Normal file
79
src/design-system/composites/KpiStrip/KpiStrip.module.css
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
.kpiStrip {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiCard {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiCard:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiCard::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--kpi-border-color), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trendSuccess { color: var(--success); }
|
||||||
|
.trendWarning { color: var(--warning); }
|
||||||
|
.trendError { color: var(--error); }
|
||||||
|
.trendMuted { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline {
|
||||||
|
margin-top: 8px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
86
src/design-system/composites/KpiStrip/KpiStrip.test.tsx
Normal file
86
src/design-system/composites/KpiStrip/KpiStrip.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { KpiStrip } from './KpiStrip'
|
||||||
|
import type { KpiItem } from './KpiStrip'
|
||||||
|
|
||||||
|
const sampleItems: KpiItem[] = [
|
||||||
|
{ label: 'Total', value: 42 },
|
||||||
|
{ label: 'Active', value: '18', trend: { label: '+3', variant: 'success' } },
|
||||||
|
{ label: 'Errors', value: 5, subtitle: 'last 24h', sparkline: [1, 3, 2, 5, 4] },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('KpiStrip', () => {
|
||||||
|
it('renders all items', () => {
|
||||||
|
const { container } = render(<KpiStrip items={sampleItems} />)
|
||||||
|
const cards = container.querySelectorAll('[class*="kpiCard"]')
|
||||||
|
expect(cards).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders labels and values', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
expect(screen.getByText('Total')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('42')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('18')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders trend with correct text', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
expect(screen.getByText('+3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies variant class to trend (trendSuccess)', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
const trend = screen.getByText('+3')
|
||||||
|
expect(trend.className).toContain('trendSuccess')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides trend when omitted', () => {
|
||||||
|
render(<KpiStrip items={[{ label: 'No Trend', value: 10 }]} />)
|
||||||
|
const { container } = render(<KpiStrip items={[{ label: 'No Trend2', value: 10 }]} />)
|
||||||
|
const trends = container.querySelectorAll('[class*="trend"]')
|
||||||
|
expect(trends).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders subtitle', () => {
|
||||||
|
render(<KpiStrip items={sampleItems} />)
|
||||||
|
expect(screen.getByText('last 24h')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders sparkline when data provided', () => {
|
||||||
|
const { container } = render(<KpiStrip items={sampleItems} />)
|
||||||
|
const svgs = container.querySelectorAll('svg')
|
||||||
|
expect(svgs.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className prop', () => {
|
||||||
|
const { container } = render(<KpiStrip items={sampleItems} className="custom" />)
|
||||||
|
expect(container.firstChild).toHaveClass('custom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty items array', () => {
|
||||||
|
const { container } = render(<KpiStrip items={[]} />)
|
||||||
|
const cards = container.querySelectorAll('[class*="kpiCard"]')
|
||||||
|
expect(cards).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses default border color (--amber) when borderColor omitted', () => {
|
||||||
|
const { container } = render(<KpiStrip items={[{ label: 'Default', value: 1 }]} />)
|
||||||
|
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
|
||||||
|
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--amber)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom borderColor', () => {
|
||||||
|
const items: KpiItem[] = [{ label: 'Custom', value: 1, borderColor: 'var(--teal)' }]
|
||||||
|
const { container } = render(<KpiStrip items={items} />)
|
||||||
|
const card = container.querySelector('[class*="kpiCard"]') as HTMLElement
|
||||||
|
expect(card.style.getPropertyValue('--kpi-border-color')).toBe('var(--teal)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders trend with muted variant by default', () => {
|
||||||
|
const items: KpiItem[] = [{ label: 'Muted', value: 1, trend: { label: '0%' } }]
|
||||||
|
render(<KpiStrip items={items} />)
|
||||||
|
const trend = screen.getByText('0%')
|
||||||
|
expect(trend.className).toContain('trendMuted')
|
||||||
|
})
|
||||||
|
})
|
||||||
71
src/design-system/composites/KpiStrip/KpiStrip.tsx
Normal file
71
src/design-system/composites/KpiStrip/KpiStrip.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import styles from './KpiStrip.module.css'
|
||||||
|
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
export interface KpiItem {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||||
|
subtitle?: string
|
||||||
|
sparkline?: number[]
|
||||||
|
borderColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KpiStripProps {
|
||||||
|
items: KpiItem[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendClassMap: Record<string, string> = {
|
||||||
|
success: styles.trendSuccess,
|
||||||
|
warning: styles.trendWarning,
|
||||||
|
error: styles.trendError,
|
||||||
|
muted: styles.trendMuted,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiStrip({ items, className }: KpiStripProps) {
|
||||||
|
const stripClasses = [styles.kpiStrip, className ?? ''].filter(Boolean).join(' ')
|
||||||
|
const gridStyle: CSSProperties = {
|
||||||
|
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={stripClasses} style={gridStyle}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const borderColor = item.borderColor ?? 'var(--amber)'
|
||||||
|
const cardStyle: CSSProperties & Record<string, string> = {
|
||||||
|
'--kpi-border-color': borderColor,
|
||||||
|
}
|
||||||
|
const trendVariant = item.trend?.variant ?? 'muted'
|
||||||
|
const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.label} className={styles.kpiCard} style={cardStyle}>
|
||||||
|
<div className={styles.label}>{item.label}</div>
|
||||||
|
<div className={styles.valueRow}>
|
||||||
|
<span className={styles.value}>{item.value}</span>
|
||||||
|
{item.trend && (
|
||||||
|
<span className={`${styles.trend} ${trendClass}`}>
|
||||||
|
{item.trend.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.subtitle && (
|
||||||
|
<div className={styles.subtitle}>{item.subtitle}</div>
|
||||||
|
)}
|
||||||
|
{item.sparkline && item.sparkline.length >= 2 && (
|
||||||
|
<div className={styles.sparkline}>
|
||||||
|
<Sparkline
|
||||||
|
data={item.sparkline}
|
||||||
|
color={borderColor}
|
||||||
|
width={200}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/design-system/composites/LogViewer/LogViewer.module.css
Normal file
75
src/design-system/composites/LogViewer/LogViewer.module.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
.container {
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelBadge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelInfo {
|
||||||
|
color: var(--running);
|
||||||
|
background: color-mix(in srgb, var(--running) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelWarn {
|
||||||
|
color: var(--warning);
|
||||||
|
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelError {
|
||||||
|
color: var(--error);
|
||||||
|
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelDebug {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
56
src/design-system/composites/LogViewer/LogViewer.test.tsx
Normal file
56
src/design-system/composites/LogViewer/LogViewer.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { LogViewer, type LogEntry } from './LogViewer'
|
||||||
|
|
||||||
|
const entries: LogEntry[] = [
|
||||||
|
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started' },
|
||||||
|
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
|
||||||
|
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
|
||||||
|
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('LogViewer', () => {
|
||||||
|
it('renders entries with timestamps and messages', () => {
|
||||||
|
render(<LogViewer entries={entries} />)
|
||||||
|
expect(screen.getByText('Server started')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('High memory usage')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => {
|
||||||
|
render(<LogViewer entries={entries} />)
|
||||||
|
expect(screen.getByText('INFO')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('WARN')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with custom maxHeight (number)', () => {
|
||||||
|
const { container } = render(<LogViewer entries={entries} maxHeight={300} />)
|
||||||
|
const el = container.firstElementChild as HTMLElement
|
||||||
|
expect(el.style.maxHeight).toBe('300px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with string maxHeight', () => {
|
||||||
|
const { container } = render(<LogViewer entries={entries} maxHeight="50vh" />)
|
||||||
|
const el = container.firstElementChild as HTMLElement
|
||||||
|
expect(el.style.maxHeight).toBe('50vh')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty entries', () => {
|
||||||
|
render(<LogViewer entries={[]} />)
|
||||||
|
expect(screen.getByText('No log entries.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className prop', () => {
|
||||||
|
const { container } = render(<LogViewer entries={entries} className="custom-class" />)
|
||||||
|
const el = container.firstElementChild as HTMLElement
|
||||||
|
expect(el.classList.contains('custom-class')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has role="log" for accessibility', () => {
|
||||||
|
render(<LogViewer entries={entries} />)
|
||||||
|
expect(screen.getByRole('log')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
77
src/design-system/composites/LogViewer/LogViewer.tsx
Normal file
77
src/design-system/composites/LogViewer/LogViewer.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useRef, useEffect, useCallback } from 'react'
|
||||||
|
import styles from './LogViewer.module.css'
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogViewerProps {
|
||||||
|
entries: LogEntry[]
|
||||||
|
maxHeight?: number | string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
||||||
|
info: styles.levelInfo,
|
||||||
|
warn: styles.levelWarn,
|
||||||
|
error: styles.levelError,
|
||||||
|
debug: styles.levelDebug,
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isAtBottomRef = useRef(true)
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (el && isAtBottomRef.current) {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
|
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={[styles.container, className].filter(Boolean).join(' ')}
|
||||||
|
style={{ maxHeight: heightStyle }}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
role="log"
|
||||||
|
>
|
||||||
|
{entries.map((entry, i) => (
|
||||||
|
<div key={i} className={styles.line}>
|
||||||
|
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
|
||||||
|
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
|
||||||
|
{entry.level.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className={styles.message}>{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className={styles.empty}>No log entries.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/design-system/composites/SplitPane/SplitPane.module.css
Normal file
37
src/design-system/composites/SplitPane/SplitPane.module.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
.splitPane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--split-columns, 1fr 2fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listPane {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPane {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyDetail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
69
src/design-system/composites/SplitPane/SplitPane.test.tsx
Normal file
69
src/design-system/composites/SplitPane/SplitPane.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { SplitPane } from './SplitPane'
|
||||||
|
|
||||||
|
describe('SplitPane', () => {
|
||||||
|
it('renders list and detail content', () => {
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List items</div>}
|
||||||
|
detail={<div>Detail content</div>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('List items')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Detail content')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows default empty message when detail is null', () => {
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List items</div>}
|
||||||
|
detail={null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows custom empty message', () => {
|
||||||
|
render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List items</div>}
|
||||||
|
detail={null}
|
||||||
|
emptyMessage="Pick something"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Pick something')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with different ratios (checks --split-columns CSS property)', () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List</div>}
|
||||||
|
detail={<div>Detail</div>}
|
||||||
|
ratio="1:1"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const root = container.firstChild as HTMLElement
|
||||||
|
expect(root.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List</div>}
|
||||||
|
detail={<div>Detail</div>}
|
||||||
|
ratio="2:3"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(root.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SplitPane
|
||||||
|
list={<div>List</div>}
|
||||||
|
detail={<div>Detail</div>}
|
||||||
|
className="custom-class"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class')
|
||||||
|
})
|
||||||
|
})
|
||||||
38
src/design-system/composites/SplitPane/SplitPane.tsx
Normal file
38
src/design-system/composites/SplitPane/SplitPane.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import styles from './SplitPane.module.css'
|
||||||
|
|
||||||
|
interface SplitPaneProps {
|
||||||
|
list: ReactNode
|
||||||
|
detail: ReactNode | null
|
||||||
|
emptyMessage?: string
|
||||||
|
ratio?: '1:1' | '1:2' | '2:3'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratioMap: Record<string, string> = {
|
||||||
|
'1:1': '1fr 1fr',
|
||||||
|
'1:2': '1fr 2fr',
|
||||||
|
'2:3': '2fr 3fr',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitPane({
|
||||||
|
list,
|
||||||
|
detail,
|
||||||
|
emptyMessage = 'Select an item to view details',
|
||||||
|
ratio = '1:2',
|
||||||
|
className,
|
||||||
|
}: SplitPaneProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.splitPane} ${className ?? ''}`}
|
||||||
|
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className={styles.listPane}>{list}</div>
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{detail !== null ? detail : (
|
||||||
|
<div className={styles.emptyDetail}>{emptyMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,12 +11,17 @@ export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog'
|
|||||||
export { DataTable } from './DataTable/DataTable'
|
export { DataTable } from './DataTable/DataTable'
|
||||||
export type { Column, DataTableProps } from './DataTable/types'
|
export type { Column, DataTableProps } from './DataTable/types'
|
||||||
export { DetailPanel } from './DetailPanel/DetailPanel'
|
export { DetailPanel } from './DetailPanel/DetailPanel'
|
||||||
|
export { EntityList } from './EntityList/EntityList'
|
||||||
export { Dropdown } from './Dropdown/Dropdown'
|
export { Dropdown } from './Dropdown/Dropdown'
|
||||||
export { EventFeed } from './EventFeed/EventFeed'
|
export { EventFeed } from './EventFeed/EventFeed'
|
||||||
export { GroupCard } from './GroupCard/GroupCard'
|
export { GroupCard } from './GroupCard/GroupCard'
|
||||||
|
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||||
|
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||||
export type { FeedEvent } from './EventFeed/EventFeed'
|
export type { FeedEvent } from './EventFeed/EventFeed'
|
||||||
export { FilterBar } from './FilterBar/FilterBar'
|
export { FilterBar } from './FilterBar/FilterBar'
|
||||||
export { LineChart } from './LineChart/LineChart'
|
export { LineChart } from './LineChart/LineChart'
|
||||||
|
export { LogViewer } from './LogViewer/LogViewer'
|
||||||
|
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
||||||
export { LoginDialog } from './LoginForm/LoginDialog'
|
export { LoginDialog } from './LoginForm/LoginDialog'
|
||||||
export type { LoginDialogProps } from './LoginForm/LoginDialog'
|
export type { LoginDialogProps } from './LoginForm/LoginDialog'
|
||||||
export { LoginForm } from './LoginForm/LoginForm'
|
export { LoginForm } from './LoginForm/LoginForm'
|
||||||
@@ -32,6 +37,7 @@ export { RouteFlow } from './RouteFlow/RouteFlow'
|
|||||||
export type { RouteNode } from './RouteFlow/RouteFlow'
|
export type { RouteNode } from './RouteFlow/RouteFlow'
|
||||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||||
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||||
|
export { SplitPane } from './SplitPane/SplitPane'
|
||||||
export { Tabs } from './Tabs/Tabs'
|
export { Tabs } from './Tabs/Tabs'
|
||||||
export { ToastProvider, useToast } from './Toast/Toast'
|
export { ToastProvider, useToast } from './Toast/Toast'
|
||||||
export { TreeView } from './TreeView/TreeView'
|
export { TreeView } from './TreeView/TreeView'
|
||||||
|
|||||||
@@ -11,3 +11,22 @@
|
|||||||
.accent-warning { border-top: 3px solid var(--warning); }
|
.accent-warning { border-top: 3px solid var(--warning); }
|
||||||
.accent-error { border-top: 3px solid var(--error); }
|
.accent-error { border-top: 3px solid var(--error); }
|
||||||
.accent-running { border-top: 3px solid var(--running); }
|
.accent-running { border-top: 3px solid var(--running); }
|
||||||
|
|
||||||
|
.titleHeader {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleText {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|||||||
49
src/design-system/primitives/Card/Card.test.tsx
Normal file
49
src/design-system/primitives/Card/Card.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { Card } from './Card'
|
||||||
|
|
||||||
|
describe('Card', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(<Card>Hello world</Card>)
|
||||||
|
expect(screen.getByText('Hello world')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders title when provided', () => {
|
||||||
|
render(<Card title="Status">Content</Card>)
|
||||||
|
expect(screen.getByText('Status')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render title header when title is omitted', () => {
|
||||||
|
const { container } = render(<Card>Content</Card>)
|
||||||
|
expect(container.querySelector('h3')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps children in body div when title is provided', () => {
|
||||||
|
render(<Card title="Status"><span>Content</span></Card>)
|
||||||
|
const content = screen.getByText('Content')
|
||||||
|
expect(content.parentElement).toHaveClass('body')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with accent and title together', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Card accent="success" title="Health">Content</Card>,
|
||||||
|
)
|
||||||
|
const card = container.firstChild as HTMLElement
|
||||||
|
expect(card).toHaveClass('accent-success')
|
||||||
|
expect(screen.getByText('Health')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts className prop', () => {
|
||||||
|
const { container } = render(<Card className="custom">Content</Card>)
|
||||||
|
const card = container.firstChild as HTMLElement
|
||||||
|
expect(card).toHaveClass('custom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders children directly when no title (no wrapper div)', () => {
|
||||||
|
const { container } = render(<Card><span>Direct child</span></Card>)
|
||||||
|
const card = container.firstChild as HTMLElement
|
||||||
|
const span = screen.getByText('Direct child')
|
||||||
|
expect(span.parentElement).toBe(card)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,15 +4,25 @@ import type { ReactNode } from 'react'
|
|||||||
interface CardProps {
|
interface CardProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
|
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
|
||||||
|
title?: string
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ children, accent = 'none', className }: CardProps) {
|
export function Card({ children, accent = 'none', title, className }: CardProps) {
|
||||||
const classes = [
|
const classes = [
|
||||||
styles.card,
|
styles.card,
|
||||||
accent !== 'none' ? styles[`accent-${accent}`] : '',
|
accent !== 'none' ? styles[`accent-${accent}`] : '',
|
||||||
className ?? '',
|
className ?? '',
|
||||||
].filter(Boolean).join(' ')
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
return <div className={classes}>{children}</div>
|
return (
|
||||||
|
<div className={classes}>
|
||||||
|
{title && (
|
||||||
|
<div className={styles.titleHeader}>
|
||||||
|
<h3 className={styles.titleText}>{title}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{title ? <div className={styles.body}>{children}</div> : children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.statusText {}
|
||||||
|
.success { color: var(--success); }
|
||||||
|
.warning { color: var(--warning); }
|
||||||
|
.error { color: var(--error); }
|
||||||
|
.running { color: var(--running); }
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
.bold { font-weight: 600; }
|
||||||
47
src/design-system/primitives/StatusText/StatusText.test.tsx
Normal file
47
src/design-system/primitives/StatusText/StatusText.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { StatusText } from './StatusText'
|
||||||
|
|
||||||
|
describe('StatusText', () => {
|
||||||
|
it('renders children text', () => {
|
||||||
|
render(<StatusText variant="success">Online</StatusText>)
|
||||||
|
expect(screen.getByText('Online')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders as a span element', () => {
|
||||||
|
render(<StatusText variant="success">Status</StatusText>)
|
||||||
|
const el = screen.getByText('Status')
|
||||||
|
expect(el.tagName).toBe('SPAN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies variant class', () => {
|
||||||
|
render(<StatusText variant="error">Failed</StatusText>)
|
||||||
|
expect(screen.getByText('Failed')).toHaveClass('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies bold class when bold=true', () => {
|
||||||
|
render(<StatusText variant="success" bold>OK</StatusText>)
|
||||||
|
expect(screen.getByText('OK')).toHaveClass('bold')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not apply bold class by default', () => {
|
||||||
|
render(<StatusText variant="success">OK</StatusText>)
|
||||||
|
expect(screen.getByText('OK')).not.toHaveClass('bold')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts custom className', () => {
|
||||||
|
render(<StatusText variant="muted" className="custom">Text</StatusText>)
|
||||||
|
expect(screen.getByText('Text')).toHaveClass('custom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all 5 variant classes correctly', () => {
|
||||||
|
const variants = ['success', 'warning', 'error', 'running', 'muted'] as const
|
||||||
|
for (const variant of variants) {
|
||||||
|
const { unmount } = render(
|
||||||
|
<StatusText variant={variant}>{variant}</StatusText>
|
||||||
|
)
|
||||||
|
expect(screen.getByText(variant)).toHaveClass(variant)
|
||||||
|
unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
20
src/design-system/primitives/StatusText/StatusText.tsx
Normal file
20
src/design-system/primitives/StatusText/StatusText.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import styles from './StatusText.module.css'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface StatusTextProps {
|
||||||
|
variant: 'success' | 'warning' | 'error' | 'running' | 'muted'
|
||||||
|
bold?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusText({ variant, bold = false, children, className }: StatusTextProps) {
|
||||||
|
const classes = [
|
||||||
|
styles.statusText,
|
||||||
|
styles[variant],
|
||||||
|
bold ? styles.bold : '',
|
||||||
|
className ?? '',
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
return <span className={classes}>{children}</span>
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export { Sparkline } from './Sparkline/Sparkline'
|
|||||||
export { Spinner } from './Spinner/Spinner'
|
export { Spinner } from './Spinner/Spinner'
|
||||||
export { StatCard } from './StatCard/StatCard'
|
export { StatCard } from './StatCard/StatCard'
|
||||||
export { StatusDot } from './StatusDot/StatusDot'
|
export { StatusDot } from './StatusDot/StatusDot'
|
||||||
|
export { StatusText } from './StatusText/StatusText'
|
||||||
export { Tag } from './Tag/Tag'
|
export { Tag } from './Tag/Tag'
|
||||||
export { Textarea } from './Textarea/Textarea'
|
export { Textarea } from './Textarea/Textarea'
|
||||||
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
|
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
|
||||||
|
|||||||
@@ -96,16 +96,6 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Instance count badge in group header */
|
|
||||||
.instanceCountBadge {
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-inset);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Group meta row */
|
/* Group meta row */
|
||||||
.groupMeta {
|
.groupMeta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -138,62 +128,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Instance table */
|
|
||||||
.instanceTable {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceTable thead th {
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thStatus {
|
|
||||||
width: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tdStatus {
|
|
||||||
width: 12px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Instance row */
|
|
||||||
.instanceRow {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceRow td {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceRow:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceRow:hover td {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceRowActive td {
|
|
||||||
background: var(--amber-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instanceRowActive td:first-child {
|
|
||||||
box-shadow: inset 3px 0 0 var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Instance fields */
|
/* Instance fields */
|
||||||
.instanceName {
|
.instanceName {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
|||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
|
import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
|
||||||
|
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
||||||
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
||||||
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
||||||
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
||||||
|
import type { Column } from '../../design-system/composites/DataTable/types'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||||
@@ -143,6 +145,72 @@ export function AgentHealth() {
|
|||||||
// Build trend data for selected instance
|
// Build trend data for selected instance
|
||||||
const trendData = selectedInstance ? buildTrendData(selectedInstance) : null
|
const trendData = selectedInstance ? buildTrendData(selectedInstance) : null
|
||||||
|
|
||||||
|
// Column definitions for the instance DataTable
|
||||||
|
const instanceColumns: Column<AgentHealthData>[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: '',
|
||||||
|
width: '12px',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<StatusDot variant={row.status === 'live' ? 'live' : row.status === 'stale' ? 'stale' : 'dead'} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Instance',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<MonoText size="sm" className={styles.instanceName}>{row.name}</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
header: 'State',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<Badge
|
||||||
|
label={row.status.toUpperCase()}
|
||||||
|
color={row.status === 'live' ? 'success' : row.status === 'stale' ? 'warning' : 'error'}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'uptime',
|
||||||
|
header: 'Uptime',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<MonoText size="xs" className={styles.instanceMeta}>{row.uptime}</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tps',
|
||||||
|
header: 'TPS',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<MonoText size="xs" className={styles.instanceMeta}>{row.tps.toFixed(1)}/s</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'errorRate',
|
||||||
|
header: 'Errors',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
|
||||||
|
{row.errorRate ?? '0 err/h'}
|
||||||
|
</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastSeen',
|
||||||
|
header: 'Heartbeat',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<MonoText size="xs" className={
|
||||||
|
row.status === 'dead' ? styles.instanceHeartbeatDead :
|
||||||
|
row.status === 'stale' ? styles.instanceHeartbeatStale :
|
||||||
|
styles.instanceMeta
|
||||||
|
}>
|
||||||
|
{row.lastSeen}
|
||||||
|
</MonoText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [])
|
||||||
|
|
||||||
function handleInstanceClick(inst: AgentHealthData) {
|
function handleInstanceClick(inst: AgentHealthData) {
|
||||||
setSelectedInstance(inst)
|
setSelectedInstance(inst)
|
||||||
setPanelOpen(true)
|
setPanelOpen(true)
|
||||||
@@ -362,65 +430,14 @@ export function AgentHealth() {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
>
|
>
|
||||||
<table className={styles.instanceTable}>
|
<DataTable<AgentHealthData>
|
||||||
<thead>
|
columns={instanceColumns}
|
||||||
<tr>
|
data={group.instances}
|
||||||
<th className={styles.thStatus} />
|
onRowClick={handleInstanceClick}
|
||||||
<th>Instance</th>
|
selectedId={panelOpen ? selectedInstance?.id : undefined}
|
||||||
<th>State</th>
|
pageSize={50}
|
||||||
<th>Uptime</th>
|
flush
|
||||||
<th>TPS</th>
|
|
||||||
<th>Errors</th>
|
|
||||||
<th>Heartbeat</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{group.instances.map((inst) => (
|
|
||||||
<tr
|
|
||||||
key={inst.id}
|
|
||||||
className={[
|
|
||||||
styles.instanceRow,
|
|
||||||
selectedInstance?.id === inst.id && panelOpen ? styles.instanceRowActive : '',
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
onClick={() => handleInstanceClick(inst)}
|
|
||||||
>
|
|
||||||
<td className={styles.tdStatus}>
|
|
||||||
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Badge
|
|
||||||
label={inst.status.toUpperCase()}
|
|
||||||
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
|
|
||||||
variant="filled"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
|
|
||||||
{inst.errorRate ?? '0 err/h'}
|
|
||||||
</MonoText>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MonoText size="xs" className={
|
|
||||||
inst.status === 'dead' ? styles.instanceHeartbeatDead :
|
|
||||||
inst.status === 'stale' ? styles.instanceHeartbeatStale :
|
|
||||||
styles.instanceMeta
|
|
||||||
}>
|
|
||||||
{inst.lastSeen}
|
|
||||||
</MonoText>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</GroupCard>
|
</GroupCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const NAV_SECTIONS = [
|
|||||||
{ label: 'Spinner', href: '#spinner' },
|
{ label: 'Spinner', href: '#spinner' },
|
||||||
{ label: 'StatCard', href: '#statcard' },
|
{ label: 'StatCard', href: '#statcard' },
|
||||||
{ label: 'StatusDot', href: '#statusdot' },
|
{ label: 'StatusDot', href: '#statusdot' },
|
||||||
|
{ label: 'StatusText', href: '#statustext' },
|
||||||
{ label: 'Tag', href: '#tag' },
|
{ label: 'Tag', href: '#tag' },
|
||||||
{ label: 'Textarea', href: '#textarea' },
|
{ label: 'Textarea', href: '#textarea' },
|
||||||
{ label: 'Toggle', href: '#toggle' },
|
{ label: 'Toggle', href: '#toggle' },
|
||||||
@@ -60,12 +61,15 @@ const NAV_SECTIONS = [
|
|||||||
{ label: 'DataTable', href: '#datatable' },
|
{ label: 'DataTable', href: '#datatable' },
|
||||||
{ label: 'DetailPanel', href: '#detailpanel' },
|
{ label: 'DetailPanel', href: '#detailpanel' },
|
||||||
{ label: 'Dropdown', href: '#dropdown' },
|
{ label: 'Dropdown', href: '#dropdown' },
|
||||||
|
{ label: 'EntityList', href: '#entitylist' },
|
||||||
{ label: 'EventFeed', href: '#eventfeed' },
|
{ label: 'EventFeed', href: '#eventfeed' },
|
||||||
{ label: 'FilterBar', href: '#filterbar' },
|
{ label: 'FilterBar', href: '#filterbar' },
|
||||||
{ label: 'GroupCard', href: '#groupcard' },
|
{ label: 'GroupCard', href: '#groupcard' },
|
||||||
|
{ label: 'KpiStrip', href: '#kpistrip' },
|
||||||
{ label: 'LineChart', href: '#linechart' },
|
{ label: 'LineChart', href: '#linechart' },
|
||||||
{ label: 'LoginDialog', href: '#logindialog' },
|
{ label: 'LoginDialog', href: '#logindialog' },
|
||||||
{ label: 'LoginForm', href: '#loginform' },
|
{ label: 'LoginForm', href: '#loginform' },
|
||||||
|
{ label: 'LogViewer', href: '#logviewer' },
|
||||||
{ label: 'MenuItem', href: '#menuitem' },
|
{ label: 'MenuItem', href: '#menuitem' },
|
||||||
{ label: 'Modal', href: '#modal' },
|
{ label: 'Modal', href: '#modal' },
|
||||||
{ label: 'MultiSelect', href: '#multi-select' },
|
{ label: 'MultiSelect', href: '#multi-select' },
|
||||||
@@ -74,6 +78,7 @@ const NAV_SECTIONS = [
|
|||||||
{ label: 'RouteFlow', href: '#routeflow' },
|
{ label: 'RouteFlow', href: '#routeflow' },
|
||||||
{ label: 'SegmentedTabs', href: '#segmented-tabs' },
|
{ label: 'SegmentedTabs', href: '#segmented-tabs' },
|
||||||
{ label: 'ShortcutsBar', href: '#shortcutsbar' },
|
{ label: 'ShortcutsBar', href: '#shortcutsbar' },
|
||||||
|
{ label: 'SplitPane', href: '#splitpane' },
|
||||||
{ label: 'Tabs', href: '#tabs' },
|
{ label: 'Tabs', href: '#tabs' },
|
||||||
{ label: 'Toast', href: '#toast' },
|
{ label: 'Toast', href: '#toast' },
|
||||||
{ label: 'TreeView', href: '#treeview' },
|
{ label: 'TreeView', href: '#treeview' },
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
DetailPanel,
|
DetailPanel,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
EntityList,
|
||||||
EventFeed,
|
EventFeed,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
GroupCard,
|
GroupCard,
|
||||||
|
KpiStrip,
|
||||||
LineChart,
|
LineChart,
|
||||||
LoginDialog,
|
LoginDialog,
|
||||||
LoginForm,
|
LoginForm,
|
||||||
|
LogViewer,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Modal,
|
Modal,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
@@ -26,13 +29,14 @@ import {
|
|||||||
RouteFlow,
|
RouteFlow,
|
||||||
SegmentedTabs,
|
SegmentedTabs,
|
||||||
ShortcutsBar,
|
ShortcutsBar,
|
||||||
|
SplitPane,
|
||||||
Tabs,
|
Tabs,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
useToast,
|
useToast,
|
||||||
TreeView,
|
TreeView,
|
||||||
} from '../../../design-system/composites'
|
} from '../../../design-system/composites'
|
||||||
import type { SearchResult } from '../../../design-system/composites'
|
import type { SearchResult } from '../../../design-system/composites'
|
||||||
import { Button } from '../../../design-system/primitives'
|
import { Avatar, Badge, Button } from '../../../design-system/primitives'
|
||||||
|
|
||||||
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -178,6 +182,60 @@ const TREE_NODES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ── Sample data for new composites ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const KPI_ITEMS = [
|
||||||
|
{
|
||||||
|
label: 'Exchanges',
|
||||||
|
value: '12,847',
|
||||||
|
trend: { label: '\u2191 +8.2%', variant: 'success' as const },
|
||||||
|
subtitle: 'Last 24h',
|
||||||
|
sparkline: [40, 55, 48, 62, 70, 65, 78],
|
||||||
|
borderColor: 'var(--amber)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Error Rate',
|
||||||
|
value: '0.34%',
|
||||||
|
trend: { label: '\u2191 +0.12pp', variant: 'error' as const },
|
||||||
|
subtitle: 'Above threshold',
|
||||||
|
sparkline: [10, 12, 11, 15, 18, 22, 19],
|
||||||
|
borderColor: 'var(--error)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Avg Latency',
|
||||||
|
value: '142ms',
|
||||||
|
trend: { label: '\u2193 -12ms', variant: 'success' as const },
|
||||||
|
subtitle: 'P95: 380ms',
|
||||||
|
borderColor: 'var(--success)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Active Routes',
|
||||||
|
value: '37',
|
||||||
|
trend: { label: '\u00b10', variant: 'muted' as const },
|
||||||
|
subtitle: '3 paused',
|
||||||
|
borderColor: 'var(--running)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ENTITY_LIST_ITEMS = [
|
||||||
|
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
|
||||||
|
{ id: '2', name: 'Bob Chen', email: 'bob@example.com', role: 'Editor' },
|
||||||
|
{ id: '3', name: 'Carol Smith', email: 'carol@example.com', role: 'Viewer' },
|
||||||
|
{ id: '4', name: 'David Park', email: 'david@example.com', role: 'Editor' },
|
||||||
|
{ id: '5', name: 'Eva Martinez', email: 'eva@example.com', role: 'Admin' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LOG_ENTRIES = [
|
||||||
|
{ timestamp: '2026-03-24T10:00:01Z', level: 'info' as const, message: 'Route timer-aggregator started successfully' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:03Z', level: 'debug' as const, message: 'Polling endpoint https://api.internal/health \u2014 200 OK' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:15Z', level: 'warn' as const, message: 'Retry queue depth at 847 \u2014 approaching threshold (1000)' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:22Z', level: 'error' as const, message: 'Exchange failed: Connection refused to jdbc:postgresql://db-primary:5432/orders' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:23Z', level: 'info' as const, message: 'Failover activated \u2014 routing to db-secondary' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:30Z', level: 'info' as const, message: 'Exchange completed in 142ms via fallback route' },
|
||||||
|
{ timestamp: '2026-03-24T10:00:45Z', level: 'debug' as const, message: 'Metrics flush: 328 data points written to InfluxDB' },
|
||||||
|
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn' as const, message: 'Memory usage at 78% \u2014 GC scheduled' },
|
||||||
|
]
|
||||||
|
|
||||||
// ── CompositesSection ─────────────────────────────────────────────────────────
|
// ── CompositesSection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CompositesSection() {
|
export function CompositesSection() {
|
||||||
@@ -221,6 +279,10 @@ export function CompositesSection() {
|
|||||||
// MultiSelect
|
// MultiSelect
|
||||||
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
|
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
|
||||||
|
|
||||||
|
// EntityList state
|
||||||
|
const [selectedEntityId, setSelectedEntityId] = useState<string | undefined>('1')
|
||||||
|
const [entitySearch, setEntitySearch] = useState('')
|
||||||
|
|
||||||
// LoginDialog
|
// LoginDialog
|
||||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
|
||||||
const [loginLoading, setLoginLoading] = useState(false)
|
const [loginLoading, setLoginLoading] = useState(false)
|
||||||
@@ -465,6 +527,38 @@ export function CompositesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* EntityList */}
|
||||||
|
<DemoCard
|
||||||
|
id="entitylist"
|
||||||
|
title="EntityList"
|
||||||
|
description="Searchable, selectable entity list with add button — designed to pair with SplitPane."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', height: 260 }}>
|
||||||
|
<EntityList
|
||||||
|
items={ENTITY_LIST_ITEMS.filter(u =>
|
||||||
|
u.name.toLowerCase().includes(entitySearch.toLowerCase())
|
||||||
|
)}
|
||||||
|
renderItem={(item, isSelected) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Avatar name={item.name} size="sm" />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: isSelected ? 600 : 400 }}>{item.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.email}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ marginLeft: 'auto' }}><Badge label={item.role} /></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
selectedId={selectedEntityId}
|
||||||
|
onSelect={setSelectedEntityId}
|
||||||
|
searchPlaceholder="Search users..."
|
||||||
|
onSearch={setEntitySearch}
|
||||||
|
addLabel="+ Add user"
|
||||||
|
onAdd={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 11b. GroupCard */}
|
{/* 11b. GroupCard */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="groupcard"
|
id="groupcard"
|
||||||
@@ -484,6 +578,17 @@ export function CompositesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* KpiStrip */}
|
||||||
|
<DemoCard
|
||||||
|
id="kpistrip"
|
||||||
|
title="KpiStrip"
|
||||||
|
description="Horizontal row of KPI cards with coloured left border, trend indicator, subtitle, and optional sparkline."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<KpiStrip items={KPI_ITEMS} />
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 12. FilterBar */}
|
{/* 12. FilterBar */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="filterbar"
|
id="filterbar"
|
||||||
@@ -562,6 +667,17 @@ export function CompositesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* LogViewer */}
|
||||||
|
<DemoCard
|
||||||
|
id="logviewer"
|
||||||
|
title="LogViewer"
|
||||||
|
description="Scrollable log output with timestamped, severity-coloured monospace entries and auto-scroll."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<LogViewer entries={LOG_ENTRIES} maxHeight={240} />
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 14. MenuItem */}
|
{/* 14. MenuItem */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="menuitem"
|
id="menuitem"
|
||||||
@@ -707,6 +823,33 @@ export function CompositesSection() {
|
|||||||
/>
|
/>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* SplitPane */}
|
||||||
|
<DemoCard
|
||||||
|
id="splitpane"
|
||||||
|
title="SplitPane"
|
||||||
|
description="Two-column master/detail layout with configurable ratio and empty-state placeholder."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', height: 200 }}>
|
||||||
|
<SplitPane
|
||||||
|
list={
|
||||||
|
<div style={{ padding: 16, fontSize: 13 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>Items</div>
|
||||||
|
<div>Item A</div>
|
||||||
|
<div>Item B</div>
|
||||||
|
<div>Item C</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
detail={
|
||||||
|
<div style={{ padding: 16, fontSize: 13 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>Detail View</div>
|
||||||
|
<div>Select an item on the left to see its details here.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
ratio="1:2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 19. Tabs */}
|
{/* 19. Tabs */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="tabs"
|
id="tabs"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
StatCard,
|
StatCard,
|
||||||
StatusDot,
|
StatusDot,
|
||||||
|
StatusText,
|
||||||
Tag,
|
Tag,
|
||||||
Textarea,
|
Textarea,
|
||||||
Toggle,
|
Toggle,
|
||||||
@@ -204,12 +205,18 @@ export function PrimitivesSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="card"
|
id="card"
|
||||||
title="Card"
|
title="Card"
|
||||||
description="Surface container with optional left-border accent colour."
|
description="Surface container with optional left-border accent colour and title header."
|
||||||
>
|
>
|
||||||
<Card><div style={{ padding: '8px 12px', fontSize: 13 }}>Plain card</div></Card>
|
<Card><div style={{ padding: '8px 12px', fontSize: 13 }}>Plain card</div></Card>
|
||||||
<Card accent="amber"><div style={{ padding: '8px 12px', fontSize: 13 }}>Amber accent</div></Card>
|
<Card accent="amber"><div style={{ padding: '8px 12px', fontSize: 13 }}>Amber accent</div></Card>
|
||||||
<Card accent="success"><div style={{ padding: '8px 12px', fontSize: 13 }}>Success accent</div></Card>
|
<Card accent="success"><div style={{ padding: '8px 12px', fontSize: 13 }}>Success accent</div></Card>
|
||||||
<Card accent="error"><div style={{ padding: '8px 12px', fontSize: 13 }}>Error accent</div></Card>
|
<Card accent="error"><div style={{ padding: '8px 12px', fontSize: 13 }}>Error accent</div></Card>
|
||||||
|
<Card title="Throughput (msg/s)">
|
||||||
|
<div style={{ padding: '8px 12px', fontSize: 13 }}>Card with title header and separator</div>
|
||||||
|
</Card>
|
||||||
|
<Card accent="amber" title="Error Rate">
|
||||||
|
<div style={{ padding: '8px 12px', fontSize: 13 }}>Title + accent combined</div>
|
||||||
|
</Card>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 6. Checkbox */}
|
{/* 6. Checkbox */}
|
||||||
@@ -559,7 +566,31 @@ export function PrimitivesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 29. Tag */}
|
{/* 29. StatusText */}
|
||||||
|
<DemoCard
|
||||||
|
id="statustext"
|
||||||
|
title="StatusText"
|
||||||
|
description="Inline coloured text for status values — five semantic variants with optional bold."
|
||||||
|
>
|
||||||
|
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
|
||||||
|
<div className={styles.demoAreaRow}>
|
||||||
|
<StatusText variant="success">99.8% uptime</StatusText>
|
||||||
|
<StatusText variant="warning">SLA at risk</StatusText>
|
||||||
|
<StatusText variant="error">BREACH</StatusText>
|
||||||
|
<StatusText variant="running">Processing</StatusText>
|
||||||
|
<StatusText variant="muted">N/A</StatusText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.demoAreaRow}>
|
||||||
|
<StatusText variant="success" bold>99.8% uptime</StatusText>
|
||||||
|
<StatusText variant="warning" bold>SLA at risk</StatusText>
|
||||||
|
<StatusText variant="error" bold>BREACH</StatusText>
|
||||||
|
<StatusText variant="running" bold>Processing</StatusText>
|
||||||
|
<StatusText variant="muted" bold>N/A</StatusText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 30. Tag */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="tag"
|
id="tag"
|
||||||
title="Tag"
|
title="Tag"
|
||||||
@@ -573,7 +604,7 @@ export function PrimitivesSection() {
|
|||||||
<Tag label="removable" color="primary" onRemove={() => undefined} />
|
<Tag label="removable" color="primary" onRemove={() => undefined} />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 30. Textarea */}
|
{/* 31. Textarea */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="textarea"
|
id="textarea"
|
||||||
title="Textarea"
|
title="Textarea"
|
||||||
@@ -582,7 +613,7 @@ export function PrimitivesSection() {
|
|||||||
<Textarea placeholder="Enter a description…" style={{ width: 280 }} />
|
<Textarea placeholder="Enter a description…" style={{ width: 280 }} />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 31. Toggle */}
|
{/* 32. Toggle */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="toggle"
|
id="toggle"
|
||||||
title="Toggle"
|
title="Toggle"
|
||||||
@@ -602,7 +633,7 @@ export function PrimitivesSection() {
|
|||||||
<Toggle label="Locked off" disabled />
|
<Toggle label="Locked off" disabled />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 32. Tooltip */}
|
{/* 33. Tooltip */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="tooltip"
|
id="tooltip"
|
||||||
title="Tooltip"
|
title="Tooltip"
|
||||||
|
|||||||
Reference in New Issue
Block a user