diff --git a/apps/www/src/content/docs/components/progress/demo.ts b/apps/www/src/content/docs/components/progress/demo.ts new file mode 100644 index 00000000..314a008a --- /dev/null +++ b/apps/www/src/content/docs/components/progress/demo.ts @@ -0,0 +1,160 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: any) => { + return ``; +}; + +export const playground = { + type: 'playground', + controls: { + value: { type: 'number', initialValue: 40, min: 0, max: 100 }, + variant: { + type: 'select', + initialValue: 'linear', + options: ['linear', 'circular'] + }, + min: { type: 'number', defaultValue: 0, min: 0, max: 99 }, + max: { type: 'number', defaultValue: 100, min: 1, max: 100 } + }, + getCode +}; + +export const directUsageDemo = { + type: 'code', + code: ` + + + +` +}; + +export const variantDemo = { + type: 'code', + tabs: [ + { + name: 'Linear', + code: ` + + + Uploading... + + + + +` + }, + { + name: 'Circular', + code: ` + + + + + + + + + + + + +` + } + ] +}; + +export const customizationDemo = { + type: 'code', + tabs: [ + { + name: 'Linear', + code: ` + + + + + + + + + +` + }, + { + name: 'Circular', + code: ` + + + + + + + + + + + + + + + + +` + } + ] +}; + +export const animatedDemo = { + type: 'code', + code: `function AnimatedProgress() { + const [value, setValue] = React.useState(0); + + React.useEffect(() => { + const interval = setInterval(() => { + setValue((current) => { + if (current >= 100) return 0; + return Math.min(100, Math.round(current + Math.random() * 25)); + }); + }, 1000); + return () => clearInterval(interval); + }, []); + + return ( + + + + Uploading... + + + + + + + + + + ); +}` +}; + +export const withLabelsDemo = { + type: 'code', + code: ` + + + Uploading files... + + + + + + + Processing... + + + + +` +}; diff --git a/apps/www/src/content/docs/components/progress/index.mdx b/apps/www/src/content/docs/components/progress/index.mdx new file mode 100644 index 00000000..e25b2e8d --- /dev/null +++ b/apps/www/src/content/docs/components/progress/index.mdx @@ -0,0 +1,93 @@ +--- +title: Progress +description: Displays the completion progress of a task. +source: packages/raystack/components/progress +tag: new +--- + +import { playground, directUsageDemo, variantDemo, customizationDemo, animatedDemo, withLabelsDemo } from "./demo.ts"; + + + +## Anatomy + +Import and assemble the component: + +```tsx +import { Progress } from '@raystack/apsara' + +{/* Direct usage — renders track automatically */} + + +{/* Composable usage */} + + Uploading... + + + +``` + +## API Reference + +### Root + +The main container for the progress. Renders a default track when no children are provided. Set `value` to `null` for an indeterminate state. + + + +### Label + +Displays a label for the progress. + + + +### Value + +Displays the formatted current value as a percentage. + + + +### Track + +Contains the indicator that visualizes the current value. + + + +## Examples + +### Variant + +The progress supports `linear` and `circular` variants. + + + +### Direct Usage + +The simplest way to use the progress. When no children are provided, it renders the track automatically. + + + +### Customization + +Customize the track for both variants. For linear, use `height` on the track. For circular, use `width`/`height` on the track to control the overall size and `--rs-progress-track-size` to control the stroke thickness. + + + +### Animated + +Use controlled `value` to animate the progress indicator. + + + +### With Labels + +Compose with `Progress.Label` and `Progress.Value` for additional context. + + + +## Accessibility + +- Uses the `progressbar` ARIA role +- Sets `aria-valuenow`, `aria-valuemin`, and `aria-valuemax` attributes +- Supports indeterminate state when `value` is `null` +- Supports `aria-label` and `aria-valuetext` for screen readers diff --git a/apps/www/src/content/docs/components/progress/props.ts b/apps/www/src/content/docs/components/progress/props.ts new file mode 100644 index 00000000..a8523387 --- /dev/null +++ b/apps/www/src/content/docs/components/progress/props.ts @@ -0,0 +1,46 @@ +export interface ProgressProps { + /** + * The current value. Set to `null` for indeterminate state. + * @default 0 + */ + value?: number | null; + + /** + * Minimum value. + * @default 0 + */ + min?: number; + + /** + * Maximum value. + * @default 100 + */ + max?: number; + + /** + * The visual style of the progress. + * @default "linear" + */ + variant?: 'linear' | 'circular'; + + /** Additional CSS class name. */ + className?: string; +} + +export interface ProgressLabelProps { + /** The label text content. */ + children: React.ReactNode; + + /** Additional CSS class name. */ + className?: string; +} + +export interface ProgressValueProps { + /** Additional CSS class name. */ + className?: string; +} + +export interface ProgressTrackProps { + /** Additional CSS class name. */ + className?: string; +} diff --git a/packages/raystack/components/progress/__tests__/progress.test.tsx b/packages/raystack/components/progress/__tests__/progress.test.tsx new file mode 100644 index 00000000..780df8f6 --- /dev/null +++ b/packages/raystack/components/progress/__tests__/progress.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Progress } from '../progress'; +import styles from '../progress.module.css'; + +describe('Progress', () => { + describe('Basic Rendering', () => { + it('renders progress element', () => { + const { container } = render(); + const progress = container.querySelector(`.${styles.progress}`); + expect(progress).toBeInTheDocument(); + }); + + it('forwards ref correctly', () => { + const ref = vi.fn(); + render(); + expect(ref).toHaveBeenCalled(); + }); + + it('applies custom className', () => { + const { container } = render( + + ); + const progress = container.querySelector(`.${styles.progress}`); + expect(progress).toHaveClass('custom-progress'); + }); + + it('renders track and indicator by default', () => { + const { container } = render(); + expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument(); + expect( + container.querySelector(`.${styles.indicator}`) + ).toBeInTheDocument(); + }); + + it('renders default track when no children provided', () => { + const { container } = render(); + expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument(); + }); + }); + + describe('Variants', () => { + it('defaults to linear variant', () => { + const { container } = render(); + const progress = container.querySelector(`.${styles.progress}`); + expect(progress).not.toHaveClass(styles['progress-variant-circular']); + }); + + it('renders circular variant', () => { + const { container } = render(); + const progress = container.querySelector(`.${styles.progress}`); + expect(progress).toHaveClass(styles['progress-variant-circular']); + }); + + it('renders SVG track and indicator for circular variant', () => { + const { container } = render(); + expect(container.querySelector('svg')).toBeInTheDocument(); + expect( + container.querySelector(`.${styles.circularTrackCircle}`) + ).toBeInTheDocument(); + expect( + container.querySelector(`.${styles.circularIndicatorCircle}`) + ).toBeInTheDocument(); + }); + }); + + describe('Sub-components', () => { + it('renders Label sub-component', () => { + render( + + Loading + + + ); + expect(screen.getByText('Loading')).toBeInTheDocument(); + }); + + it('renders Value sub-component', () => { + render( + + + + + ); + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + + it('renders custom children instead of default track', () => { + const { container } = render( + + Custom + + + ); + expect(screen.getByText('Custom')).toBeInTheDocument(); + expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument(); + }); + }); + + describe('Indeterminate', () => { + it('supports null value for indeterminate state', () => { + render(); + const progress = screen.getByRole('progressbar'); + expect(progress).not.toHaveAttribute('aria-valuenow'); + }); + }); + + describe('Accessibility', () => { + it('has progressbar role', () => { + render(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('sets aria-valuenow', () => { + render(); + const progress = screen.getByRole('progressbar'); + expect(progress).toHaveAttribute('aria-valuenow', '75'); + }); + + it('sets aria-valuemin and aria-valuemax', () => { + render(); + const progress = screen.getByRole('progressbar'); + expect(progress).toHaveAttribute('aria-valuemin', '0'); + expect(progress).toHaveAttribute('aria-valuemax', '200'); + }); + }); +}); diff --git a/packages/raystack/components/progress/index.tsx b/packages/raystack/components/progress/index.tsx new file mode 100644 index 00000000..711a1140 --- /dev/null +++ b/packages/raystack/components/progress/index.tsx @@ -0,0 +1 @@ +export { Progress } from './progress'; diff --git a/packages/raystack/components/progress/progress-misc.tsx b/packages/raystack/components/progress/progress-misc.tsx new file mode 100644 index 00000000..f1dd42f7 --- /dev/null +++ b/packages/raystack/components/progress/progress-misc.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { Progress as ProgressPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { type ElementRef, forwardRef } from 'react'; +import styles from './progress.module.css'; + +export const ProgressLabel = forwardRef< + ElementRef, + ProgressPrimitive.Label.Props +>(({ className, ...props }, ref) => ( + +)); + +ProgressLabel.displayName = 'ProgressLabel'; + +export const ProgressValue = forwardRef< + ElementRef, + ProgressPrimitive.Value.Props +>(({ className, ...props }, ref) => ( + +)); + +ProgressValue.displayName = 'ProgressValue'; diff --git a/packages/raystack/components/progress/progress-root.tsx b/packages/raystack/components/progress/progress-root.tsx new file mode 100644 index 00000000..f02adedd --- /dev/null +++ b/packages/raystack/components/progress/progress-root.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Progress as ProgressPrimitive } from '@base-ui/react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { createContext, type ElementRef, forwardRef } from 'react'; +import styles from './progress.module.css'; +import { ProgressTrack } from './progress-track'; + +const progress = cva(styles.progress, { + variants: { + variant: { + linear: '', + circular: styles['progress-variant-circular'] + } + }, + defaultVariants: { + variant: 'linear' + } +}); + +export interface ProgressProps + extends ProgressPrimitive.Root.Props, + VariantProps {} + +export interface ProgressContextValue { + variant: 'linear' | 'circular'; + value: number | null; + percentage: number; +} + +export const ProgressContext = createContext({ + variant: 'linear', + value: 0, + percentage: 0 +}); + +export const ProgressRoot = forwardRef< + ElementRef, + ProgressProps +>( + ( + { + className, + style = {}, + variant = 'linear', + children = , + value = 0, + min = 0, + max = 100, + ...props + }, + ref + ) => { + const percentage = value === null ? 0 : ((value - min) * 100) / (max - min); + + return ( + + + {children} + + + ); + } +); + +ProgressRoot.displayName = 'ProgressRoot'; diff --git a/packages/raystack/components/progress/progress-track.tsx b/packages/raystack/components/progress/progress-track.tsx new file mode 100644 index 00000000..9c8e6ab8 --- /dev/null +++ b/packages/raystack/components/progress/progress-track.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { Progress as ProgressPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { type ElementRef, forwardRef, useContext } from 'react'; +import styles from './progress.module.css'; +import { ProgressContext } from './progress-root'; + +export const ProgressTrack = forwardRef< + ElementRef, + ProgressPrimitive.Track.Props +>(({ className, children, ...props }, ref) => { + const { variant } = useContext(ProgressContext); + + if (variant === 'circular') { + return ( + ( + + + {trackChildren} + + )} + > + } + /> + {children} + + ); + } + + return ( + + + {children} + + ); +}); + +ProgressTrack.displayName = 'ProgressTrack'; diff --git a/packages/raystack/components/progress/progress.module.css b/packages/raystack/components/progress/progress.module.css new file mode 100644 index 00000000..324d5a0a --- /dev/null +++ b/packages/raystack/components/progress/progress.module.css @@ -0,0 +1,97 @@ +.progress { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); + width: 100%; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.label { + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); +} + +.value { + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); + text-align: right; +} + +.track { + position: relative; + width: 100%; + height: 4px; + overflow: clip; + border-radius: 1px; + background-color: var(--rs-color-background-neutral-secondary); +} + +.indicator { + height: 100%; + background-color: var(--rs-color-background-accent-emphasis); + transition: width 500ms; +} + +/* Circular variant — viewBox is 72×72, SVG scales to container size */ +.progress-variant-circular { + align-items: center; + justify-content: center; + position: relative; +} + +.circularSvg { + --rs-progress-track-size: 4px; + --rs-progress-radius: calc((72px - var(--rs-progress-track-size) * 2) / 2); + --rs-progress-circumference: calc(2 * 3.14159265 * var(--rs-progress-radius)); + width: 72px; + height: 72px; + aspect-ratio: 1; + transform: rotate(-90deg); +} + +.circularTrackCircle, +.circularIndicatorCircle { + cx: 50%; + cy: 50%; + r: var(--rs-progress-radius); + stroke-width: var(--rs-progress-track-size); + fill: none; +} + +.circularTrackCircle { + stroke: var(--rs-color-background-neutral-secondary); +} + +.circularIndicatorCircle { + stroke: var(--rs-color-background-accent-emphasis); + stroke-dasharray: var(--rs-progress-circumference); + stroke-dashoffset: calc( + var(--rs-progress-circumference) * + (1 - var(--rs-progress-percentage, 0) / 100) + ); + stroke-linecap: butt; + transition: stroke-dashoffset 500ms; +} + +.progress-variant-circular .value { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-weight: var(--rs-font-weight-medium); + text-align: center; + white-space: nowrap; +} diff --git a/packages/raystack/components/progress/progress.tsx b/packages/raystack/components/progress/progress.tsx new file mode 100644 index 00000000..bdbf1cd8 --- /dev/null +++ b/packages/raystack/components/progress/progress.tsx @@ -0,0 +1,9 @@ +import { ProgressLabel, ProgressValue } from './progress-misc'; +import { ProgressRoot } from './progress-root'; +import { ProgressTrack } from './progress-track'; + +export const Progress = Object.assign(ProgressRoot, { + Label: ProgressLabel, + Value: ProgressValue, + Track: ProgressTrack +}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 95a9e531..70ee3ed7 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -46,6 +46,7 @@ export { Menu } from './components/menu'; export { Navbar } from './components/navbar'; export { Popover } from './components/popover'; export { PreviewCard } from './components/preview-card'; +export { Progress } from './components/progress'; export { Radio } from './components/radio'; export { ScrollArea } from './components/scroll-area'; export { Search } from './components/search';