Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions src/components/atoms/progress/Progress.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Progress> = {
title: 'Atoms/Progress',
component: Progress,
parameters: {
docs: {
autodocs: true
}
},
tags: ['autodocs']
};
export default meta;

type Story = StoryObj<typeof Progress>;

/**
* 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: () => (
<div className='flex flex-col gap-4 rounded bg-background-light p-4 dark:bg-background-dark'>
<Progress value={60} size='sm' label='Small' className='w-96' />
<Progress value={60} size='md' label='Medium' className='w-96' />
<Progress value={60} size='lg' label='Large' className='w-96' />
</div>
)
};

/**
* Shows the available color variants.
*/
export const Colors: Story = {
render: () => (
<div className='flex flex-col gap-4 rounded bg-background-light p-4 dark:bg-background-dark'>
<Progress value={50} color='default' label='Default' className='w-96' />
<Progress value={50} color='primary' label='Primary' className='w-96' />
<Progress value={50} color='secondary' label='Secondary' className='w-96' />
<Progress value={50} color='success' label='Success' className='w-96' />
<Progress value={50} color='warning' label='Warning' className='w-96' />
<Progress value={50} color='danger' label='Danger' className='w-96' />
</div>
)
};

/**
* Shows the available rounded variants.
*/
export const Rounded: Story = {
render: () => (
<div className='flex flex-col gap-4 rounded bg-background-light p-4 dark:bg-background-dark'>
<Progress value={70} rounded='none' label='None' className='w-96' />
<Progress value={70} rounded='sm' label='Small' className='w-96' />
<Progress value={70} rounded='md' label='Medium' className='w-96' />
<Progress value={70} rounded='lg' label='Large' className='w-96' />
<Progress value={70} rounded='full' label='Full' className='w-96' />
</div>
)
};

/**
* Shows labels and percentage values.
*/
export const WithValueLabel: Story = {
render: () => (
<div className='flex flex-col gap-4 rounded bg-background-light p-4 dark:bg-background-dark'>
<Progress value={45} label='Uploading files' className='w-96' />
<Progress value={75} label='Processing' showValueLabel={true} className='w-96' />
<Progress value={100} label='Complete' showValueLabel={true} className='w-96' />
</div>
)
};

/**
* Shows the indeterminate loading state.
*/
export const Indeterminate: Story = {
render: () => (
<div className='rounded bg-background-light p-4 dark:bg-background-dark'>
<Progress isIndeterminate={true} label='Loading' color='primary' className='w-96' />
</div>
)
};

/**
* Shows custom numeric ranges.
*/
export const CustomRange: Story = {
render: () => (
<div className='flex flex-col gap-4 rounded bg-background-light p-4 dark:bg-background-dark'>
<Progress value={50} minValue={0} maxValue={100} label='0 to 100' showValueLabel={true} className='w-96' />
<Progress value={75} minValue={0} maxValue={200} label='0 to 200' showValueLabel={true} className='w-96' />
<Progress value={150} minValue={50} maxValue={200} label='50 to 200' showValueLabel={true} className='w-96' />
</div>
)
};
130 changes: 130 additions & 0 deletions src/components/atoms/progress/Progress.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Progress isIndeterminate={true} label='Loading' />);

const progressbar = screen.getByRole('progressbar');
expect(progressbar.getAttribute('aria-valuenow')).toBeNull();
});

it('renders the label and a stable percentage label', () => {
render(<Progress value={40} minValue={10} maxValue={10} label='Upload progress' showValueLabel={true} />);

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(
<Progress
value={Number.NaN}
minValue={Number.NEGATIVE_INFINITY}
maxValue={Number.POSITIVE_INFINITY}
label='Upload progress'
showValueLabel={true}
/>
);

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(
<>
<span id='external-label'>External progress label</span>
<Progress
id='download-progress'
data-state='ready'
aria-labelledby='external-label'
label='Internal label'
value={25}
/>
</>
);

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(<Progress value={10} />);

const progressbar = screen.getByRole('progressbar');
expect(progressbar.getAttribute('aria-label')).toBe('Progress');
});
});
45 changes: 45 additions & 0 deletions src/components/atoms/progress/Progress.tsx
Original file line number Diff line number Diff line change
@@ -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<ProgressProps> = (props) => {
const {
ariaLabel,
ariaLabelledBy,
ariaValueMax,
ariaValueMin,
ariaValueNow,
className,
indicatorClassName,
indicatorStyle,
label,
rootProps,
trackClassName,
valueLabel
} = useProgress(props);

return (
<div
{...rootProps}
className={cn('w-full', className)}
role='progressbar'
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-valuemin={ariaValueMin}
aria-valuemax={ariaValueMax}
aria-valuenow={ariaValueNow}
>
{(label || valueLabel) && (
<div className='mb-2 flex items-center justify-between gap-2'>
{label && <span className='text-sm text-text-light dark:text-text-dark'>{label}</span>}
{valueLabel && <span className='text-sm font-medium text-text-light dark:text-text-dark'>{valueLabel}</span>}
</div>
)}

<div className={trackClassName}>
<div className={indicatorClassName} style={indicatorStyle} />
</div>
</div>
);
};
2 changes: 2 additions & 0 deletions src/components/atoms/progress/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Progress } from './Progress';
export * from './types';
Loading
Loading