diff --git a/src/design-system/composites/KpiStrip/KpiStrip.module.css b/src/design-system/composites/KpiStrip/KpiStrip.module.css new file mode 100644 index 0000000..0c94875 --- /dev/null +++ b/src/design-system/composites/KpiStrip/KpiStrip.module.css @@ -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; +} diff --git a/src/design-system/composites/KpiStrip/KpiStrip.test.tsx b/src/design-system/composites/KpiStrip/KpiStrip.test.tsx new file mode 100644 index 0000000..371bdd3 --- /dev/null +++ b/src/design-system/composites/KpiStrip/KpiStrip.test.tsx @@ -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() + const cards = container.querySelectorAll('[class*="kpiCard"]') + expect(cards).toHaveLength(3) + }) + + it('renders labels and values', () => { + render() + 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() + expect(screen.getByText('+3')).toBeInTheDocument() + }) + + it('applies variant class to trend (trendSuccess)', () => { + render() + const trend = screen.getByText('+3') + expect(trend.className).toContain('trendSuccess') + }) + + it('hides trend when omitted', () => { + render() + const { container } = render() + const trends = container.querySelectorAll('[class*="trend"]') + expect(trends).toHaveLength(0) + }) + + it('renders subtitle', () => { + render() + expect(screen.getByText('last 24h')).toBeInTheDocument() + }) + + it('renders sparkline when data provided', () => { + const { container } = render() + const svgs = container.querySelectorAll('svg') + expect(svgs.length).toBeGreaterThanOrEqual(1) + }) + + it('accepts className prop', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom') + }) + + it('handles empty items array', () => { + const { container } = render() + const cards = container.querySelectorAll('[class*="kpiCard"]') + expect(cards).toHaveLength(0) + }) + + it('uses default border color (--amber) when borderColor omitted', () => { + const { container } = render() + 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() + 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() + const trend = screen.getByText('0%') + expect(trend.className).toContain('trendMuted') + }) +}) diff --git a/src/design-system/composites/KpiStrip/KpiStrip.tsx b/src/design-system/composites/KpiStrip/KpiStrip.tsx new file mode 100644 index 0000000..76fd3ae --- /dev/null +++ b/src/design-system/composites/KpiStrip/KpiStrip.tsx @@ -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 = { + 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 ( +
+ {items.map((item) => { + const borderColor = item.borderColor ?? 'var(--amber)' + const cardStyle: CSSProperties & Record = { + '--kpi-border-color': borderColor, + } + const trendVariant = item.trend?.variant ?? 'muted' + const trendClass = trendClassMap[trendVariant] ?? styles.trendMuted + + return ( +
+
{item.label}
+
+ {item.value} + {item.trend && ( + + {item.trend.label} + + )} +
+ {item.subtitle && ( +
{item.subtitle}
+ )} + {item.sparkline && item.sparkline.length >= 2 && ( +
+ +
+ )} +
+ ) + })} +
+ ) +} diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index 4a4ec9b..7fe8060 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -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'