feat: add ProgressBar primitive
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
100
src/design-system/primitives/ProgressBar/ProgressBar.test.tsx
Normal file
100
src/design-system/primitives/ProgressBar/ProgressBar.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
60
src/design-system/primitives/ProgressBar/ProgressBar.tsx
Normal file
60
src/design-system/primitives/ProgressBar/ProgressBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user