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 { EventFeed } from './EventFeed/EventFeed'
|
||||
export { GroupCard } from './GroupCard/GroupCard'
|
||||
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||
export type { FeedEvent } from './EventFeed/EventFeed'
|
||||
export { FilterBar } from './FilterBar/FilterBar'
|
||||
export { LineChart } from './LineChart/LineChart'
|
||||
|
||||
Reference in New Issue
Block a user