feat: add KpiStrip composite for horizontal metric card rows
Horizontal grid of KPI cards with labels, values, trend indicators, subtitles, and optional sparklines. Uses CSS custom property for per-card accent border color. 12 tests included. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export { DetailPanel } from './DetailPanel/DetailPanel'
|
|||||||
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'
|
||||||
|
|||||||
Reference in New Issue
Block a user