Progress and Stepper #33

Merged
an.di merged 3 commits from adilallo/component/ProgressStepper into main 2026-02-02 18:47:36 +00:00
13 changed files with 697 additions and 0 deletions
+128
View File
@@ -4,6 +4,8 @@ import { useState } from "react";
import Tooltip from "../components/Tooltip"; import Tooltip from "../components/Tooltip";
import Alert from "../components/Alert"; import Alert from "../components/Alert";
import Button from "../components/Button"; import Button from "../components/Button";
import Stepper from "../components/Stepper";
import Progress from "../components/Progress";
export default function ComponentsPreview() { export default function ComponentsPreview() {
const [alertVisible, setAlertVisible] = useState({ const [alertVisible, setAlertVisible] = useState({
@@ -178,6 +180,132 @@ export default function ComponentsPreview() {
</div> </div>
</div> </div>
</section> </section>
{/* Stepper Section */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Stepper Component
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<div className="space-y-[var(--spacing-scale-016)]">
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Step 1 of 5
</p>
<Stepper active={1} totalSteps={5} />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Step 2 of 5
</p>
<Stepper active={2} totalSteps={5} />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Step 3 of 5
</p>
<Stepper active={3} totalSteps={5} />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Step 4 of 5
</p>
<Stepper active={4} totalSteps={5} />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Step 5 of 5
</p>
<Stepper active={5} totalSteps={5} />
</div>
</div>
</div>
</section>
{/* Progress Section */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Progress Component
</h2>
<div className="bg-white rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<div className="space-y-[var(--spacing-scale-016)]">
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 1-0
</p>
<Progress progress="1-0" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 1-1
</p>
<Progress progress="1-1" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 1-2
</p>
<Progress progress="1-2" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 1-3
</p>
<Progress progress="1-3" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 1-4
</p>
<Progress progress="1-4" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 1-5
</p>
<Progress progress="1-5" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 2-0
</p>
<Progress progress="2-0" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 2-1
</p>
<Progress progress="2-1" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 2-2
</p>
<Progress progress="2-2" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 3-0
</p>
<Progress progress="3-0" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 3-1
</p>
<Progress progress="3-1" />
</div>
<div>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-008)]">
Progress: 3-2
</p>
<Progress progress="3-2" />
</div>
</div>
</div>
</section>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,23 @@
"use client";
import { memo } from "react";
import { ProgressView } from "./Progress.view";
import type { ProgressProps } from "./Progress.types";
const ProgressContainer = memo<ProgressProps>(
({ progress = "3-2", className = "" }) => {
const barClasses = `h-[8px] relative w-full`;
return (
<ProgressView
progress={progress}
className={className}
barClasses={barClasses}
/>
);
},
);
ProgressContainer.displayName = "Progress";
export default ProgressContainer;
+24
View File
@@ -0,0 +1,24 @@
export type ProgressBarState =
| "1-0"
| "1-1"
| "1-2"
| "1-3"
| "1-4"
| "1-5"
| "2-0"
| "2-1"
| "2-2"
| "3-0"
| "3-1"
| "3-2";
export interface ProgressProps {
progress?: ProgressBarState;
className?: string;
}
export interface ProgressViewProps {
progress: ProgressBarState;
className: string;
barClasses: string;
}
+80
View File
@@ -0,0 +1,80 @@
import type { ProgressViewProps } from "./Progress.types";
export function ProgressView({
progress,
className,
barClasses,
}: ProgressViewProps) {
// Progress bar type
const [fullSegments, partialSegment] = progress.split("-").map(Number);
// Calculate total progress:
// - For 1-X: first section is (X+1)/6 filled
// - For 2-X: first section full, second section X/3 filled
// - For 3-X: first two sections full, third section X/3 filled
// Max is 3 full segments = 9 units
let totalProgress = 0;
if (fullSegments === 1) {
totalProgress = (partialSegment + 1) / 6; // 1/6 to 6/6 of first section
} else if (fullSegments === 2) {
totalProgress = 1 + partialSegment / 3; // 1 full + 0/3 to 2/3 of second
} else if (fullSegments === 3) {
totalProgress = 2 + partialSegment / 3; // 2 full + 0/3 to 2/3 of third
}
const maxProgress = 3;
const progressPercentage = Math.round((totalProgress / maxProgress) * 100);
return (
<div
className={`${barClasses} ${className}`}
role="progressbar"
aria-valuenow={totalProgress}
aria-valuemin={0}
aria-valuemax={3}
aria-label={`Progress: ${progressPercentage}%`}
>
{/* Background layer - 3 segments */}
<div className="absolute inset-0 flex gap-[var(--spacing-scale-008)] px-[4px]">
<div className="flex-1 h-full bg-[var(--color-surface-default-secondary)] rounded-l-[var(--radius-full)]" />
<div className="flex-1 h-full bg-[var(--color-surface-default-secondary)]" />
<div className="flex-1 h-full bg-[var(--color-surface-default-secondary)] rounded-r-[var(--radius-full)]" />
</div>
{/* Fill layer - always show 3 sections, fill amount varies */}
<div className="absolute inset-0 flex gap-[var(--spacing-scale-008)] px-[4px] overflow-hidden">
{/* First section - for 1-X: (X+1)/6 filled, for 2-X and 3-X: fully filled */}
<div className="flex-1 h-full relative">
{fullSegments === 1 ? (
<div
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]"
style={{ width: `${((partialSegment + 1) / 6) * 100}%` }}
/>
) : fullSegments >= 2 ? (
<div className="absolute inset-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]" />
) : null}
</div>
{/* Second section - for 2-X: X/3 filled, for 3-X: fully filled, otherwise empty */}
<div className="flex-1 h-full relative">
{fullSegments === 2 ? (
partialSegment > 0 ? (
<div
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)]"
style={{ width: `${(partialSegment / 3) * 100}%` }}
/>
) : null
) : fullSegments >= 3 ? (
<div className="absolute inset-0 bg-[var(--color-content-default-brand-primary)]" />
) : null}
</div>
{/* Third section - for 3-X: X/3 filled, otherwise empty */}
<div className="flex-1 h-full relative">
{fullSegments === 3 && partialSegment > 0 ? (
<div
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)]"
style={{ width: `${(partialSegment / 3) * 100}%` }}
/>
) : null}
</div>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "./Progress.container";
@@ -0,0 +1,24 @@
"use client";
import { memo } from "react";
import { StepperView } from "./Stepper.view";
import type { StepperProps } from "./Stepper.types";
const StepperContainer = memo<StepperProps>(
({ active = 1, totalSteps = 5, className = "" }) => {
const stepperClasses = `flex gap-[var(--spacing-scale-012)] items-center relative`;
return (
<StepperView
active={active}
totalSteps={totalSteps}
className={className}
stepperClasses={stepperClasses}
/>
);
},
);
StepperContainer.displayName = "Stepper";
export default StepperContainer;
+14
View File
@@ -0,0 +1,14 @@
export type StepperActive = 1 | 2 | 3 | 4 | 5;
export interface StepperProps {
active?: StepperActive;
totalSteps?: number;
className?: string;
}
export interface StepperViewProps {
active: StepperActive;
totalSteps: number;
className: string;
stepperClasses: string;
}
+50
View File
@@ -0,0 +1,50 @@
import type { StepperViewProps } from "./Stepper.types";
export function StepperView({
active,
totalSteps,
className,
stepperClasses,
}: StepperViewProps) {
return (
<div
className={`${stepperClasses} ${className}`}
role="progressbar"
aria-valuenow={active}
aria-valuemin={1}
aria-valuemax={totalSteps}
aria-label={`Step ${active} of ${totalSteps}`}
>
{Array.from({ length: totalSteps }, (_, index) => {
const stepNumber = index + 1;
const isActive = stepNumber === active;
return (
<div
key={stepNumber}
className="shrink-0 w-[12px] h-[12px] flex items-center justify-center"
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
className={isActive ? "opacity-100" : "opacity-30"}
>
<circle
cx="6"
cy="6"
r="5.5"
fill="var(--color-surface-inverse-secondary)"
stroke="var(--color-surface-inverse-secondary)"
strokeWidth="1"
/>
</svg>
</div>
);
})}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "./Stepper.container";
+106
View File
@@ -0,0 +1,106 @@
import Progress from "../app/components/Progress";
export default {
title: "Components/Progress",
component: Progress,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Progress bar component for showing completion percentage. Displays a 3-segment progress bar with support for partial fills.",
},
},
},
argTypes: {
progress: {
control: { type: "select" },
options: [
"1-0",
"1-1",
"1-2",
"1-3",
"1-4",
"1-5",
"2-0",
"2-1",
"2-2",
"3-0",
"3-1",
"3-2",
],
description: "Progress state (format: segments-partial)",
},
},
tags: ["autodocs"],
};
export const Default = {
args: {
progress: "3-2",
},
render: (args) => <Progress {...args} />,
};
export const AllStates = {
args: {},
render: (_args) => (
<div className="space-y-4 w-full max-w-[600px]">
<div>
<p className="text-white mb-2">1-0</p>
<Progress {..._args} progress="1-0" />
</div>
<div>
<p className="text-white mb-2">1-1</p>
<Progress {..._args} progress="1-1" />
</div>
<div>
<p className="text-white mb-2">1-2</p>
<Progress {..._args} progress="1-2" />
</div>
<div>
<p className="text-white mb-2">1-3</p>
<Progress {..._args} progress="1-3" />
</div>
<div>
<p className="text-white mb-2">1-4</p>
<Progress {..._args} progress="1-4" />
</div>
<div>
<p className="text-white mb-2">1-5</p>
<Progress {..._args} progress="1-5" />
</div>
<div>
<p className="text-white mb-2">2-0</p>
<Progress {..._args} progress="2-0" />
</div>
<div>
<p className="text-white mb-2">2-1</p>
<Progress {..._args} progress="2-1" />
</div>
<div>
<p className="text-white mb-2">2-2</p>
<Progress {..._args} progress="2-2" />
</div>
<div>
<p className="text-white mb-2">3-0</p>
<Progress {..._args} progress="3-0" />
</div>
<div>
<p className="text-white mb-2">3-1</p>
<Progress {..._args} progress="3-1" />
</div>
<div>
<p className="text-white mb-2">3-2</p>
<Progress {..._args} progress="3-2" />
</div>
</div>
),
parameters: {
docs: {
description: {
story: "Different progress states of the progress bar component.",
},
},
},
};
+97
View File
@@ -0,0 +1,97 @@
import Stepper from "../app/components/Stepper";
export default {
title: "Components/Stepper",
component: Stepper,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Stepper component for showing progress through multi-step processes. Displays a series of steps with active steps highlighted.",
},
},
},
argTypes: {
active: {
control: { type: "number", min: 1, max: 5 },
description: "The active step number",
},
totalSteps: {
control: { type: "number", min: 1, max: 10 },
description: "Total number of steps",
},
},
tags: ["autodocs"],
};
export const Default = {
args: {
active: 1,
totalSteps: 5,
},
};
export const AllStates = {
args: {
totalSteps: 5,
},
render: (_args) => (
<div className="space-y-4">
<div>
<p className="text-white mb-2">Step 1 of 5</p>
<Stepper {..._args} active={1} />
</div>
<div>
<p className="text-white mb-2">Step 2 of 5</p>
<Stepper {..._args} active={2} />
</div>
<div>
<p className="text-white mb-2">Step 3 of 5</p>
<Stepper {..._args} active={3} />
</div>
<div>
<p className="text-white mb-2">Step 4 of 5</p>
<Stepper {..._args} active={4} />
</div>
<div>
<p className="text-white mb-2">Step 5 of 5</p>
<Stepper {..._args} active={5} />
</div>
</div>
),
parameters: {
docs: {
description: {
story: "Different active states of the stepper component.",
},
},
},
};
export const DifferentStepCounts = {
args: {},
render: () => (
<div className="space-y-4">
<div>
<p className="text-white mb-2">3 Steps - Step 2</p>
<Stepper active={2} totalSteps={3} />
</div>
<div>
<p className="text-white mb-2">5 Steps - Step 3</p>
<Stepper active={3} totalSteps={5} />
</div>
<div>
<p className="text-white mb-2">7 Steps - Step 4</p>
<Stepper active={4} totalSteps={7} />
</div>
</div>
),
parameters: {
docs: {
description: {
story: "Stepper with different total step counts.",
},
},
},
};
+77
View File
@@ -0,0 +1,77 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import Progress from "../../app/components/Progress";
import {
componentTestSuite,
ComponentTestSuiteConfig,
} from "../utils/componentTestSuite";
type ProgressProps = React.ComponentProps<typeof Progress>;
const baseProps: ProgressProps = {
progress: "2-1",
};
const config: ComponentTestSuiteConfig<ProgressProps> = {
component: Progress,
name: "Progress",
props: baseProps,
requiredProps: [],
optionalProps: {
progress: "3-2",
className: "custom-class",
},
primaryRole: "progressbar",
testCases: {
renders: true,
accessibility: true,
keyboardNavigation: false, // Progress is not keyboard navigable
disabledState: false, // Progress doesn't have disabled state
errorState: false,
},
};
componentTestSuite<ProgressProps>(config);
describe("Progress (behavioral tests)", () => {
it("renders progress bar with correct progress value", () => {
render(<Progress progress="2-1" />);
const progressbar = screen.getByRole("progressbar");
// 2-1: First section full (1) + second section 1/3 filled = 1 + 1/3 ≈ 1.333
expect(progressbar).toHaveAttribute("aria-valuenow", "1.3333333333333333");
expect(progressbar).toHaveAttribute("aria-valuemin", "0");
expect(progressbar).toHaveAttribute("aria-valuemax", "3");
});
it("applies custom className", () => {
const { container } = render(<Progress className="custom-class" />);
expect(container.firstChild).toHaveClass("custom-class");
});
it("defaults to progress 3-2 when progress is not specified", () => {
render(<Progress />);
const progressbar = screen.getByRole("progressbar");
// 3-2: First two sections full (2) + third section 2/3 filled = 2 + 2/3 ≈ 2.667
expect(progressbar).toHaveAttribute("aria-valuenow", "2.6666666666666665");
});
it("handles all progress states correctly", () => {
const testCases = [
{ progress: "1-0" as const, expected: 1 / 6 }, // First section 1/6 filled
{ progress: "1-5" as const, expected: 1 }, // First section 6/6 filled (fully filled)
{ progress: "2-0" as const, expected: 1 }, // First section full, second empty
{ progress: "2-2" as const, expected: 1 + 2 / 3 }, // First section full, second section 2/3 filled
{ progress: "3-0" as const, expected: 2 }, // First two sections full, third empty
{ progress: "3-2" as const, expected: 2 + 2 / 3 }, // First two sections full, third section 2/3 filled
];
testCases.forEach(({ progress, expected }) => {
const { unmount } = render(<Progress progress={progress} />);
const progressbar = screen.getByRole("progressbar");
expect(progressbar).toHaveAttribute("aria-valuenow", String(expected));
unmount();
});
});
});
+72
View File
@@ -0,0 +1,72 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import Stepper from "../../app/components/Stepper";
import {
componentTestSuite,
ComponentTestSuiteConfig,
} from "../utils/componentTestSuite";
type StepperProps = React.ComponentProps<typeof Stepper>;
const baseProps: StepperProps = {
active: 1,
totalSteps: 5,
};
const config: ComponentTestSuiteConfig<StepperProps> = {
component: Stepper,
name: "Stepper",
props: baseProps,
requiredProps: [],
optionalProps: {
active: 3,
totalSteps: 3,
className: "custom-class",
},
primaryRole: "progressbar",
testCases: {
renders: true,
accessibility: true,
keyboardNavigation: false, // Stepper is not keyboard navigable
disabledState: false, // Stepper doesn't have disabled state
errorState: false,
},
};
componentTestSuite<StepperProps>(config);
describe("Stepper (behavioral tests)", () => {
it("renders with correct number of steps", () => {
render(<Stepper active={3} totalSteps={5} />);
const progressbar = screen.getByRole("progressbar");
expect(progressbar).toHaveAttribute("aria-valuenow", "3");
expect(progressbar).toHaveAttribute("aria-valuemin", "1");
expect(progressbar).toHaveAttribute("aria-valuemax", "5");
});
it("shows active steps correctly", () => {
const { container } = render(<Stepper active={2} totalSteps={5} />);
// Should have 1 active (filled) circle (step 2) and 4 inactive (outline) circles
const svgs = container.querySelectorAll("svg");
expect(svgs.length).toBe(5);
});
it("applies custom className", () => {
const { container } = render(<Stepper className="custom-class" />);
expect(container.firstChild).toHaveClass("custom-class");
});
it("defaults to active 1 when active is not specified", () => {
render(<Stepper totalSteps={5} />);
const progressbar = screen.getByRole("progressbar");
expect(progressbar).toHaveAttribute("aria-valuenow", "1");
});
it("defaults to totalSteps 5 when totalSteps is not specified", () => {
render(<Stepper active={1} />);
const progressbar = screen.getByRole("progressbar");
expect(progressbar).toHaveAttribute("aria-valuemax", "5");
});
});