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