From f91d152c7935d6312e4c31c8067244fdd190709f Mon Sep 17 00:00:00 2001 From: maciker Date: Thu, 28 May 2026 21:04:48 +0200 Subject: [PATCH] feat(progress): add Progress component --- .../atoms/progress/Progress.stories.tsx | 114 ++++++++++ .../atoms/progress/Progress.test.tsx | 130 +++++++++++ src/components/atoms/progress/Progress.tsx | 45 ++++ src/components/atoms/progress/index.ts | 2 + src/components/atoms/progress/types.ts | 127 +++++++++++ src/components/atoms/progress/useProgress.ts | 95 ++++++++ src/index.ts | 1 + src/styles/animations.css | 211 ++++++++++-------- 8 files changed, 630 insertions(+), 95 deletions(-) create mode 100644 src/components/atoms/progress/Progress.stories.tsx create mode 100644 src/components/atoms/progress/Progress.test.tsx create mode 100644 src/components/atoms/progress/Progress.tsx create mode 100644 src/components/atoms/progress/index.ts create mode 100644 src/components/atoms/progress/types.ts create mode 100644 src/components/atoms/progress/useProgress.ts diff --git a/src/components/atoms/progress/Progress.stories.tsx b/src/components/atoms/progress/Progress.stories.tsx new file mode 100644 index 00000000..77b4d510 --- /dev/null +++ b/src/components/atoms/progress/Progress.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Progress } from './Progress'; + +/** + * ## Description + * Progress communicates how much of a task has completed within a bounded range. + * Use the indeterminate state when the work is in progress but the exact completion value is unknown. + */ +const meta: Meta = { + title: 'Atoms/Progress', + component: Progress, + parameters: { + docs: { + autodocs: true + } + }, + tags: ['autodocs'] +}; +export default meta; + +type Story = StoryObj; + +/** + * Shows the default Progress configuration using the component default variants. + */ +export const Default: Story = { + args: { + value: 50, + label: 'Upload progress', + showValueLabel: true, + className: 'w-96' + } +}; + +/** + * Shows the available size variants. + */ +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ) +}; + +/** + * Shows the available color variants. + */ +export const Colors: Story = { + render: () => ( +
+ + + + + + +
+ ) +}; + +/** + * Shows the available rounded variants. + */ +export const Rounded: Story = { + render: () => ( +
+ + + + + +
+ ) +}; + +/** + * Shows labels and percentage values. + */ +export const WithValueLabel: Story = { + render: () => ( +
+ + + +
+ ) +}; + +/** + * Shows the indeterminate loading state. + */ +export const Indeterminate: Story = { + render: () => ( +
+ +
+ ) +}; + +/** + * Shows custom numeric ranges. + */ +export const CustomRange: Story = { + render: () => ( +
+ + + +
+ ) +}; diff --git a/src/components/atoms/progress/Progress.test.tsx b/src/components/atoms/progress/Progress.test.tsx new file mode 100644 index 00000000..a57e5c2a --- /dev/null +++ b/src/components/atoms/progress/Progress.test.tsx @@ -0,0 +1,130 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Progress } from './Progress'; +import { useProgress } from './useProgress'; + +describe('useProgress', () => { + it('calculates the default percentage for the default range', () => { + const { result } = renderHook(() => useProgress({ value: 50 })); + + expect(result.current.clampedValue).toBe(50); + expect(result.current.percentage).toBe(50); + expect(result.current.ariaValueMin).toBe(0); + expect(result.current.ariaValueMax).toBe(100); + expect(result.current.ariaValueNow).toBe(50); + }); + + it('clamps values below the minimum range', () => { + const { result } = renderHook(() => useProgress({ value: -10, minValue: 10, maxValue: 30 })); + + expect(result.current.clampedValue).toBe(10); + expect(result.current.percentage).toBe(0); + }); + + it('clamps values above the maximum range', () => { + const { result } = renderHook(() => useProgress({ value: 99, minValue: 10, maxValue: 30 })); + + expect(result.current.clampedValue).toBe(30); + expect(result.current.percentage).toBe(100); + }); + + it('supports custom minimum and maximum values', () => { + const { result } = renderHook(() => useProgress({ value: 125, minValue: 50, maxValue: 250 })); + + expect(result.current.clampedValue).toBe(125); + expect(result.current.percentage).toBeCloseTo(37.5); + }); + + it('stays stable when minValue and maxValue are equal', () => { + const { result } = renderHook(() => useProgress({ value: 40, minValue: 10, maxValue: 10 })); + + expect(result.current.clampedValue).toBe(10); + expect(result.current.percentage).toBe(0); + expect(result.current.ariaValueMax).toBe(10); + expect(result.current.ariaValueNow).toBe(10); + }); + + it('stays stable when maxValue is lower than minValue', () => { + const { result } = renderHook(() => useProgress({ value: 40, minValue: 20, maxValue: 10 })); + + expect(result.current.clampedValue).toBe(20); + expect(result.current.percentage).toBe(0); + expect(result.current.ariaValueMax).toBe(20); + expect(result.current.ariaValueNow).toBe(20); + }); + + it('sanitizes non-finite numeric inputs', () => { + const { result } = renderHook(() => + useProgress({ value: Number.NaN, minValue: Number.NEGATIVE_INFINITY, maxValue: Number.POSITIVE_INFINITY }) + ); + + expect(result.current.clampedValue).toBe(0); + expect(result.current.percentage).toBe(0); + expect(result.current.ariaValueMin).toBe(0); + expect(result.current.ariaValueMax).toBe(100); + expect(result.current.ariaValueNow).toBe(0); + }); +}); + +describe('Progress', () => { + it('omits aria-valuenow when indeterminate', () => { + render(); + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar.getAttribute('aria-valuenow')).toBeNull(); + }); + + it('renders the label and a stable percentage label', () => { + render(); + + screen.getByText('Upload progress'); + screen.getByText('0%'); + expect(screen.queryByText('NaN%')).toBeNull(); + expect(screen.queryByText('Infinity%')).toBeNull(); + }); + + it('does not render NaN or Infinity when numeric props are not finite', () => { + render( + + ); + + screen.getByText('0%'); + expect(screen.queryByText('NaN%')).toBeNull(); + expect(screen.queryByText('Infinity%')).toBeNull(); + }); + + it('forwards native props and lets aria-labelledby override the default aria-label', () => { + render( + <> + External progress label + + + ); + + const progressbar = screen.getByRole('progressbar'); + + expect(progressbar.id).toBe('download-progress'); + expect(progressbar.getAttribute('data-state')).toBe('ready'); + expect(progressbar.getAttribute('aria-labelledby')).toBe('external-label'); + expect(progressbar.getAttribute('aria-label')).toBeNull(); + }); + + it('falls back to a default aria-label when no external label is provided', () => { + render(); + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar.getAttribute('aria-label')).toBe('Progress'); + }); +}); diff --git a/src/components/atoms/progress/Progress.tsx b/src/components/atoms/progress/Progress.tsx new file mode 100644 index 00000000..076d1bc0 --- /dev/null +++ b/src/components/atoms/progress/Progress.tsx @@ -0,0 +1,45 @@ +import type { FC } from 'react'; +import { cn } from '@/lib/utils'; +import type { ProgressProps } from './types'; +import { useProgress } from './useProgress'; + +export const Progress: FC = (props) => { + const { + ariaLabel, + ariaLabelledBy, + ariaValueMax, + ariaValueMin, + ariaValueNow, + className, + indicatorClassName, + indicatorStyle, + label, + rootProps, + trackClassName, + valueLabel + } = useProgress(props); + + return ( +
+ {(label || valueLabel) && ( +
+ {label && {label}} + {valueLabel && {valueLabel}} +
+ )} + +
+
+
+
+ ); +}; diff --git a/src/components/atoms/progress/index.ts b/src/components/atoms/progress/index.ts new file mode 100644 index 00000000..807160ae --- /dev/null +++ b/src/components/atoms/progress/index.ts @@ -0,0 +1,2 @@ +export { Progress } from './Progress'; +export * from './types'; diff --git a/src/components/atoms/progress/types.ts b/src/components/atoms/progress/types.ts new file mode 100644 index 00000000..f7cb0a28 --- /dev/null +++ b/src/components/atoms/progress/types.ts @@ -0,0 +1,127 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; + +export const progressVariants = cva('relative w-full overflow-hidden', { + variants: { + size: { + sm: 'h-1', + md: 'h-2', + lg: 'h-3' + }, + rounded: { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + full: 'rounded-full' + }, + color: { + default: 'bg-red-tint-subtle dark:bg-red-tint-low', + primary: 'bg-red-tint-low dark:bg-red-tint-active', + secondary: 'bg-border-light dark:bg-surface-raised-dark', + success: 'bg-success-tint', + warning: 'bg-warning-tint', + danger: 'bg-error-tint' + } + }, + defaultVariants: { + size: 'md', + rounded: 'full', + color: 'default' + } +}); + +export const progressIndicatorVariants = cva('h-full origin-left', { + variants: { + rounded: { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + full: 'rounded-full' + }, + color: { + default: 'bg-brand-light dark:bg-brand-dark', + primary: 'bg-brand-light-dark dark:bg-brand-dark-light', + secondary: 'bg-text-secondary-light dark:bg-border-strong-dark', + success: 'bg-success-light dark:bg-success', + warning: 'bg-warning-light dark:bg-warning', + danger: 'bg-error-light dark:bg-error' + } + }, + defaultVariants: { + rounded: 'full', + color: 'default' + } +}); + +type ProgressTrackVariantProps = VariantProps; +type ProgressIndicatorVariantProps = VariantProps; +type NativeProgressProps = Omit< + ComponentProps<'div'>, + 'aria-valuemax' | 'aria-valuemin' | 'aria-valuenow' | 'children' | 'className' | 'color' | 'role' +>; + +export type ProgressSize = NonNullable; +export type ProgressColor = NonNullable; +export type ProgressRounded = NonNullable; + +export type ProgressProps = NativeProgressProps & { + /** + * @control number + * @default 0 + */ + value?: number; + + /** + * @control number + * @default 0 + */ + minValue?: number; + + /** + * @control number + * @default 100 + */ + maxValue?: number; + + /** + * @control boolean + * @default false + */ + isIndeterminate?: boolean; + + /** + * @control text + */ + label?: string; + + /** + * @control boolean + * @default false + */ + showValueLabel?: boolean; + + /** + * @control select + * @default md + */ + size?: ProgressSize; + + /** + * @control select + * @default default + */ + color?: ProgressColor; + + /** + * @control select + * @default full + */ + rounded?: ProgressRounded; + + /** + * @control text + */ + className?: string; +}; diff --git a/src/components/atoms/progress/useProgress.ts b/src/components/atoms/progress/useProgress.ts new file mode 100644 index 00000000..0b810504 --- /dev/null +++ b/src/components/atoms/progress/useProgress.ts @@ -0,0 +1,95 @@ +import type { CSSProperties } from 'react'; +import { cn } from '@/lib/utils'; +import { type ProgressProps, progressIndicatorVariants, progressVariants } from './types'; + +type ProgressNativeProps = Omit< + ProgressProps, + | 'aria-label' + | 'aria-labelledby' + | 'className' + | 'color' + | 'isIndeterminate' + | 'label' + | 'maxValue' + | 'minValue' + | 'rounded' + | 'showValueLabel' + | 'size' + | 'value' +>; + +export type UseProgressReturn = { + ariaLabel: string | undefined; + ariaLabelledBy: ProgressProps['aria-labelledby']; + ariaValueMax: number; + ariaValueMin: number; + ariaValueNow: number | undefined; + className: ProgressProps['className']; + clampedValue: number; + indicatorClassName: string; + indicatorStyle: CSSProperties | undefined; + isIndeterminate: boolean; + label: ProgressProps['label']; + percentage: number; + rootProps: ProgressNativeProps; + trackClassName: string; + valueLabel: string | undefined; +}; + +export const useProgress = ({ + value = 0, + minValue = 0, + maxValue = 100, + isIndeterminate = false, + label, + showValueLabel = false, + size = 'md', + color = 'default', + rounded = 'full', + className, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + ...restProps +}: ProgressProps): UseProgressReturn => { + const safeMinValue = Number.isFinite(minValue) ? minValue : 0; + const safeMaxValue = Number.isFinite(maxValue) ? maxValue : 100; + const safeValue = Number.isFinite(value) ? value : safeMinValue; + const hasValidRange = safeMaxValue > safeMinValue; + const clampedValue = hasValidRange ? Math.min(Math.max(safeValue, safeMinValue), safeMaxValue) : safeMinValue; + const percentage = hasValidRange ? ((clampedValue - safeMinValue) / (safeMaxValue - safeMinValue)) * 100 : 0; + const boundedPercentage = Math.min(Math.max(percentage, 0), 100); + + const trackClassName = progressVariants({ + size, + rounded, + color + }); + + const indicatorClassName = cn( + progressIndicatorVariants({ + rounded, + color + }), + isIndeterminate + ? 'progress-indeterminate-bar' + : 'w-full transition-transform duration-300 ease-in-out motion-reduce:transition-none' + ); + + return { + ariaLabel: ariaLabelledBy ? undefined : (ariaLabel ?? label ?? 'Progress'), + ariaLabelledBy, + ariaValueMax: hasValidRange ? safeMaxValue : safeMinValue, + ariaValueMin: safeMinValue, + ariaValueNow: isIndeterminate ? undefined : clampedValue, + className, + clampedValue, + indicatorClassName, + indicatorStyle: isIndeterminate ? undefined : { width: '100%', transform: `scaleX(${boundedPercentage / 100})` }, + isIndeterminate, + label, + percentage: boundedPercentage, + rootProps: restProps, + trackClassName, + valueLabel: showValueLabel && !isIndeterminate ? `${Math.round(boundedPercentage)}%` : undefined + }; +}; diff --git a/src/index.ts b/src/index.ts index 78b23151..95e16845 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ export { Icon } from './components/atoms/icon'; export { IconButton } from './components/atoms/icon-button'; export { Input } from './components/atoms/input'; export { Link } from './components/atoms/link'; +export { Progress } from './components/atoms/progress'; export { Skeleton } from './components/atoms/skeleton'; export { Spacer } from './components/atoms/spacer'; export { Switch } from './components/atoms/switch'; diff --git a/src/styles/animations.css b/src/styles/animations.css index 3e6a680c..ac138498 100644 --- a/src/styles/animations.css +++ b/src/styles/animations.css @@ -1,95 +1,116 @@ -@keyframes fadeIn { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} -@keyframes fadeOut { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} -@keyframes rotation-360 { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} -@keyframes rotation-360-reverse { - 0% { - transform: rotate(360deg); - } - 100% { - transform: rotate(0deg); - } -} -@keyframes animate1 { - 0% { - left: -100%; - } - 50%, - 100% { - left: 100%; - } -} -@keyframes animate2 { - 0% { - top: -100%; - } - 50%, - 100% { - top: 100%; - } -} -@keyframes animate3 { - 0% { - right: -100%; - } - 50%, - 100% { - right: 100%; - } -} -@keyframes animate4 { - 0% { - bottom: -100%; - } - 50%, - 100% { - bottom: 100%; - } -} -@keyframes badgeIn { - 0% { - transform: scale(0.8); - opacity: 0; - } - 100% { - transform: scale(1); - opacity: 1; - } -} -@keyframes badgeOut { - 0% { - transform: scale(1); - opacity: 1; - } - 100% { - transform: scale(0.8); - opacity: 0; - } -} -@keyframes badgePing { - 75%, - 100% { - transform: scale(1.4); - opacity: 0; - } -} +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +@keyframes fadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} +@keyframes rotation-360 { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} +@keyframes rotation-360-reverse { + 0% { + transform: rotate(360deg); + } + 100% { + transform: rotate(0deg); + } +} +@keyframes animate1 { + 0% { + left: -100%; + } + 50%, + 100% { + left: 100%; + } +} +@keyframes animate2 { + 0% { + top: -100%; + } + 50%, + 100% { + top: 100%; + } +} +@keyframes animate3 { + 0% { + right: -100%; + } + 50%, + 100% { + right: 100%; + } +} +@keyframes animate4 { + 0% { + bottom: -100%; + } + 50%, + 100% { + bottom: 100%; + } +} +@keyframes badgeIn { + 0% { + transform: scale(0.8); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +} +@keyframes badgeOut { + 0% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(0.8); + opacity: 0; + } +} +@keyframes badgePing { + 75%, + 100% { + transform: scale(1.4); + opacity: 0; + } +} +@keyframes progress-indeterminate { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } +} + +.progress-indeterminate-bar { + width: 30%; + will-change: transform; + animation: progress-indeterminate 1.5s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .progress-indeterminate-bar { + animation: none; + transform: translateX(0); + } +}