From ba48f677d9389d0b2b94d3d81456f72d477f6055 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:56:34 +0100 Subject: [PATCH] feat: add ProgressBar primitive Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProgressBar/ProgressBar.module.css | 48 +++++++++ .../ProgressBar/ProgressBar.test.tsx | 100 ++++++++++++++++++ .../primitives/ProgressBar/ProgressBar.tsx | 60 +++++++++++ 3 files changed, 208 insertions(+) create mode 100644 src/design-system/primitives/ProgressBar/ProgressBar.module.css create mode 100644 src/design-system/primitives/ProgressBar/ProgressBar.test.tsx create mode 100644 src/design-system/primitives/ProgressBar/ProgressBar.tsx diff --git a/src/design-system/primitives/ProgressBar/ProgressBar.module.css b/src/design-system/primitives/ProgressBar/ProgressBar.module.css new file mode 100644 index 0000000..09731dd --- /dev/null +++ b/src/design-system/primitives/ProgressBar/ProgressBar.module.css @@ -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; +} diff --git a/src/design-system/primitives/ProgressBar/ProgressBar.test.tsx b/src/design-system/primitives/ProgressBar/ProgressBar.test.tsx new file mode 100644 index 0000000..0335833 --- /dev/null +++ b/src/design-system/primitives/ProgressBar/ProgressBar.test.tsx @@ -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() + const bar = screen.getByRole('progressbar') + expect(bar).toBeInTheDocument() + }) + + it('sets aria-valuenow to the clamped value', () => { + render() + const bar = screen.getByRole('progressbar') + expect(bar).toHaveAttribute('aria-valuenow', '42') + }) + + it('always sets aria-valuemin=0 and aria-valuemax=100', () => { + render() + 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() + const bar = screen.getByRole('progressbar') + expect(bar).toHaveAttribute('aria-valuenow', '100') + }) + + it('clamps value below 0 to 0', () => { + render() + const bar = screen.getByRole('progressbar') + expect(bar).toHaveAttribute('aria-valuenow', '0') + }) + + it('omits aria-valuenow when indeterminate', () => { + render() + const bar = screen.getByRole('progressbar') + expect(bar).not.toHaveAttribute('aria-valuenow') + }) + + it('renders label text above the bar', () => { + render() + expect(screen.getByText('Upload progress')).toBeInTheDocument() + }) + + it('does not render label element when label is omitted', () => { + render() + expect(screen.queryByText('Upload progress')).not.toBeInTheDocument() + }) + + it('applies variant class to fill', () => { + const { container } = render() + const fill = container.querySelector('.fill') + expect(fill).toHaveClass('success') + }) + + it('applies error variant class to fill', () => { + const { container } = render() + const fill = container.querySelector('.fill') + expect(fill).toHaveClass('error') + }) + + it('applies sm size class to track', () => { + const { container } = render() + const track = screen.getByRole('progressbar') + expect(track).toHaveClass('sm') + }) + + it('applies md size class to track by default', () => { + const { container } = render() + const track = screen.getByRole('progressbar') + expect(track).toHaveClass('md') + }) + + it('applies indeterminate class to fill when indeterminate', () => { + const { container } = render() + const fill = container.querySelector('.fill') + expect(fill).toHaveClass('indeterminate') + }) + + it('sets fill width style matching value', () => { + const { container } = render() + const fill = container.querySelector('.fill') as HTMLElement + expect(fill.style.width).toBe('75%') + }) + + it('does not set width style when indeterminate', () => { + const { container } = render() + const fill = container.querySelector('.fill') as HTMLElement + expect(fill.style.width).toBe('') + }) + + it('passes through className to the track', () => { + render() + const bar = screen.getByRole('progressbar') + expect(bar).toHaveClass('custom-class') + }) +}) diff --git a/src/design-system/primitives/ProgressBar/ProgressBar.tsx b/src/design-system/primitives/ProgressBar/ProgressBar.tsx new file mode 100644 index 0000000..c2a6e9f --- /dev/null +++ b/src/design-system/primitives/ProgressBar/ProgressBar.tsx @@ -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 ( +
+ {label && ( + {label} + )} +
+
+
+
+ ) +}