feat: Sparkline, Card, StatCard, FilterPill primitives

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 09:31:55 +01:00
parent 36d6539775
commit 34d24dd434
8 changed files with 304 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.accent-amber { border-top: 3px solid var(--amber); }
.accent-success { border-top: 3px solid var(--success); }
.accent-warning { border-top: 3px solid var(--warning); }
.accent-error { border-top: 3px solid var(--error); }
.accent-running { border-top: 3px solid var(--running); }

View File

@@ -0,0 +1,18 @@
import styles from './Card.module.css'
import type { ReactNode } from 'react'
interface CardProps {
children: ReactNode
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none'
className?: string
}
export function Card({ children, accent = 'none', className }: CardProps) {
const classes = [
styles.card,
accent !== 'none' ? styles[`accent-${accent}`] : '',
className ?? '',
].filter(Boolean).join(' ')
return <div className={classes}>{children}</div>
}

View File

@@ -0,0 +1,54 @@
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--bg-raised);
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.pill:hover {
border-color: var(--text-faint);
color: var(--text-primary);
}
.active {
background: var(--amber-bg);
border-color: var(--amber-light);
color: var(--amber-deep);
font-weight: 600;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-faint);
flex-shrink: 0;
}
.label {
line-height: 1;
}
.count {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
background: var(--bg-inset);
border-radius: 8px;
padding: 0 5px;
line-height: 1.6;
}
.active .count {
background: var(--amber-light);
}

View File

@@ -0,0 +1,42 @@
import styles from './FilterPill.module.css'
interface FilterPillProps {
label: string
count?: number
active?: boolean
dot?: boolean
dotColor?: string
onClick?: () => void
className?: string
}
export function FilterPill({
label,
count,
active = false,
dot = false,
dotColor,
onClick,
className,
}: FilterPillProps) {
const classes = [
styles.pill,
active ? styles.active : '',
className ?? '',
].filter(Boolean).join(' ')
return (
<button className={classes} onClick={onClick} type="button">
{dot && (
<span
className={styles.dot}
style={dotColor ? { background: dotColor } : undefined}
/>
)}
<span className={styles.label}>{label}</span>
{count !== undefined && (
<span className={styles.count}>{count}</span>
)}
</button>
)
}

View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Sparkline } from './Sparkline'
describe('Sparkline', () => {
it('renders an SVG with a polyline', () => {
const { container } = render(<Sparkline data={[1, 3, 2, 5, 4]} />)
expect(container.querySelector('svg')).toBeInTheDocument()
expect(container.querySelector('polyline')).toBeInTheDocument()
})
it('returns null for less than 2 data points', () => {
const { container } = render(<Sparkline data={[1]} />)
expect(container.firstChild).toBeNull()
})
})

View File

@@ -0,0 +1,51 @@
interface SparklineProps {
data: number[]
color?: string
width?: number
height?: number
strokeWidth?: number
className?: string
}
export function Sparkline({
data,
color = 'var(--amber)',
width = 60,
height = 20,
strokeWidth = 1.5,
className,
}: SparklineProps) {
if (data.length < 2) return null
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
const padding = 1
const points = data
.map((val, i) => {
const x = (i / (data.length - 1)) * (width - padding * 2) + padding
const y = height - padding - ((val - min) / range) * (height - padding * 2)
return `${x},${y}`
})
.join(' ')
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={className}
aria-hidden="true"
>
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,62 @@
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: 16px 18px;
overflow: hidden;
}
.accent-amber { border-top: 3px solid var(--amber); }
.accent-success { border-top: 3px solid var(--success); }
.accent-warning { border-top: 3px solid var(--warning); }
.accent-error { border-top: 3px solid var(--error); }
.accent-running { border-top: 3px solid var(--running); }
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-muted);
}
.sparkline {
opacity: 0.7;
}
.valueRow {
display: flex;
align-items: baseline;
gap: 8px;
}
.value {
font-family: var(--font-mono);
font-size: 28px;
font-weight: 600;
color: var(--text-primary);
line-height: 1;
}
.trend {
font-size: 11px;
font-weight: 500;
}
.trend.up { color: var(--success); }
.trend.down { color: var(--error); }
.trend.neutral { color: var(--text-muted); }
.detail {
font-size: 11px;
color: var(--text-muted);
margin-top: 6px;
}

View File

@@ -0,0 +1,48 @@
import styles from './StatCard.module.css'
import { Sparkline } from '../Sparkline/Sparkline'
interface StatCardProps {
label: string
value: string | number
detail?: string
trend?: 'up' | 'down' | 'neutral'
trendValue?: string
accent?: 'amber' | 'success' | 'warning' | 'error' | 'running'
sparkline?: number[]
className?: string
}
const TREND_ICONS = {
up: '↑',
down: '↓',
neutral: '→',
}
export function StatCard({
label,
value,
detail,
trend,
trendValue,
accent = 'amber',
sparkline,
className,
}: StatCardProps) {
return (
<div className={`${styles.card} ${styles[`accent-${accent}`]} ${className ?? ''}`}>
<div className={styles.header}>
<span className={styles.label}>{label}</span>
{sparkline && <Sparkline data={sparkline} className={styles.sparkline} />}
</div>
<div className={styles.valueRow}>
<span className={styles.value}>{value}</span>
{trend && trendValue && (
<span className={`${styles.trend} ${styles[trend]}`}>
{TREND_ICONS[trend]} {trendValue}
</span>
)}
</div>
{detail && <div className={styles.detail}>{detail}</div>}
</div>
)
}