feat: add ProgressBar primitive

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 11:56:34 +01:00
parent afe1abf7a1
commit ba48f677d9
3 changed files with 208 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.label {
font-family: var(--font-body);
font-size: 11px;
color: var(--text-secondary);
line-height: 1;
}
.track {
width: 100%;
background: var(--bg-inset);
border-radius: 4px;
overflow: hidden;
}
.sm { height: 4px; }
.md { height: 8px; }
.fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.primary { background: var(--amber); }
.success { background: var(--success); }
.warning { background: var(--warning); }
.error { background: var(--error); }
.running { background: var(--running); }
/* Indeterminate shimmer */
@keyframes shimmer {
0% { left: -40%; width: 40%; }
50% { left: 30%; width: 60%; }
100% { left: 100%; width: 40%; }
}
.indeterminate {
position: relative;
width: 40% !important;
animation: shimmer 1.4s ease-in-out infinite;
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ProgressBar } from './ProgressBar'
describe('ProgressBar', () => {
it('renders with default props', () => {
render(<ProgressBar />)
const bar = screen.getByRole('progressbar')
expect(bar).toBeInTheDocument()
})
it('sets aria-valuenow to the clamped value', () => {
render(<ProgressBar value={42} />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveAttribute('aria-valuenow', '42')
})
it('always sets aria-valuemin=0 and aria-valuemax=100', () => {
render(<ProgressBar value={50} />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveAttribute('aria-valuemin', '0')
expect(bar).toHaveAttribute('aria-valuemax', '100')
})
it('clamps value above 100 to 100', () => {
render(<ProgressBar value={150} />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveAttribute('aria-valuenow', '100')
})
it('clamps value below 0 to 0', () => {
render(<ProgressBar value={-10} />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveAttribute('aria-valuenow', '0')
})
it('omits aria-valuenow when indeterminate', () => {
render(<ProgressBar indeterminate />)
const bar = screen.getByRole('progressbar')
expect(bar).not.toHaveAttribute('aria-valuenow')
})
it('renders label text above the bar', () => {
render(<ProgressBar label="Upload progress" value={60} />)
expect(screen.getByText('Upload progress')).toBeInTheDocument()
})
it('does not render label element when label is omitted', () => {
render(<ProgressBar value={60} />)
expect(screen.queryByText('Upload progress')).not.toBeInTheDocument()
})
it('applies variant class to fill', () => {
const { container } = render(<ProgressBar variant="success" value={50} />)
const fill = container.querySelector('.fill')
expect(fill).toHaveClass('success')
})
it('applies error variant class to fill', () => {
const { container } = render(<ProgressBar variant="error" value={50} />)
const fill = container.querySelector('.fill')
expect(fill).toHaveClass('error')
})
it('applies sm size class to track', () => {
const { container } = render(<ProgressBar size="sm" value={50} />)
const track = screen.getByRole('progressbar')
expect(track).toHaveClass('sm')
})
it('applies md size class to track by default', () => {
const { container } = render(<ProgressBar value={50} />)
const track = screen.getByRole('progressbar')
expect(track).toHaveClass('md')
})
it('applies indeterminate class to fill when indeterminate', () => {
const { container } = render(<ProgressBar indeterminate />)
const fill = container.querySelector('.fill')
expect(fill).toHaveClass('indeterminate')
})
it('sets fill width style matching value', () => {
const { container } = render(<ProgressBar value={75} />)
const fill = container.querySelector('.fill') as HTMLElement
expect(fill.style.width).toBe('75%')
})
it('does not set width style when indeterminate', () => {
const { container } = render(<ProgressBar indeterminate />)
const fill = container.querySelector('.fill') as HTMLElement
expect(fill.style.width).toBe('')
})
it('passes through className to the track', () => {
render(<ProgressBar className="custom-class" value={30} />)
const bar = screen.getByRole('progressbar')
expect(bar).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,60 @@
import styles from './ProgressBar.module.css'
type ProgressBarVariant = 'primary' | 'success' | 'warning' | 'error' | 'running'
type ProgressBarSize = 'sm' | 'md'
interface ProgressBarProps {
value?: number
variant?: ProgressBarVariant
size?: ProgressBarSize
indeterminate?: boolean
label?: string
className?: string
}
export function ProgressBar({
value = 0,
variant = 'primary',
size = 'md',
indeterminate = false,
label,
className,
}: ProgressBarProps) {
const clampedValue = Math.min(100, Math.max(0, value))
const trackClasses = [
styles.track,
styles[size],
className ?? '',
].filter(Boolean).join(' ')
const fillClasses = [
styles.fill,
styles[variant],
indeterminate ? styles.indeterminate : '',
].filter(Boolean).join(' ')
const ariaProps = indeterminate
? { 'aria-valuenow': undefined }
: { 'aria-valuenow': clampedValue }
return (
<div className={styles.wrapper}>
{label && (
<span className={styles.label}>{label}</span>
)}
<div
className={trackClasses}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
{...ariaProps}
>
<div
className={fillClasses}
style={indeterminate ? undefined : { width: `${clampedValue}%` }}
/>
</div>
</div>
)
}