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:
hsiegeln
2026-03-24 12:17:33 +01:00
parent c89c163068
commit 22c098f9b6
4 changed files with 238 additions and 0 deletions

View 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;
}

View 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')
})
})

View 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>
)
}

View File

@@ -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'