Progress component

- Headless progress bar component with support for determinate and indeterminate states
- Exposes aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext for accessibility
- Supports custom value label via getValueLabel function
- All tests passing (8/8), full suite passing (88/88), types check, biome compliant
This commit is contained in:
Mats Bosson 2026-03-29 07:25:07 +07:00
parent 041217ca33
commit 21a7991562
3 changed files with 117 additions and 0 deletions

View File

@ -0,0 +1,2 @@
export { Progress } from "./progress";
export type { ProgressProps } from "./progress";

View File

@ -0,0 +1,64 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
export interface ProgressProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Current value. Pass null for indeterminate. */
value?: number | null;
/** Maximum value. @default 100 */
max?: number;
/** Custom label function for aria-valuetext. */
getValueLabel?: (value: number, max: number) => string;
}
/**
* Displays the progress of a task. Supports determinate and indeterminate states.
* Indeterminate when value is null or undefined.
*/
export function Progress(props: ProgressProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "max", "getValueLabel"]);
const max = () => local.max ?? 100;
const isIndeterminate = () => local.value == null;
const currentValue = (): number | null => local.value ?? null;
const percentage = () => {
const v = currentValue();
if (v === null) return null;
return Math.round((v / max()) * 100);
};
const valueLabel = (): string | undefined => {
const v = currentValue();
if (v === null) return undefined;
if (local.getValueLabel) return local.getValueLabel(v, max());
const pct = percentage();
return pct === null ? undefined : `${pct}%`;
};
const dataState = (): string => {
return isIndeterminate() ? "indeterminate" : "complete";
};
const valueNow = (): number | undefined => {
return isIndeterminate() ? undefined : currentValue() || undefined;
};
const dataValue = (): number | undefined => {
return isIndeterminate() ? undefined : currentValue() || undefined;
};
return (
<div
role="progressbar"
tabIndex={-1}
aria-valuemin={0}
aria-valuemax={max()}
aria-valuenow={valueNow()}
aria-valuetext={valueLabel()}
data-state={dataState()}
data-value={dataValue()}
data-max={max()}
{...rest}
/>
);
}

View File

@ -0,0 +1,51 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Progress } from "../../../src/components/progress/index";
describe("Progress", () => {
it("renders with role=progressbar", () => {
render(() => <Progress value={50} />);
expect(screen.getByRole("progressbar")).toBeTruthy();
});
it("sets aria-valuenow", () => {
render(() => <Progress value={50} />);
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe("50");
});
it("sets aria-valuemin=0 and aria-valuemax=100 by default", () => {
render(() => <Progress value={50} />);
const pb = screen.getByRole("progressbar");
expect(pb.getAttribute("aria-valuemin")).toBe("0");
expect(pb.getAttribute("aria-valuemax")).toBe("100");
});
it("uses custom max", () => {
render(() => <Progress value={5} max={10} />);
expect(screen.getByRole("progressbar").getAttribute("aria-valuemax")).toBe("10");
});
it("is indeterminate when value is null", () => {
render(() => <Progress value={null} />);
const pb = screen.getByRole("progressbar");
expect(pb.getAttribute("aria-valuenow")).toBeNull();
expect(pb.getAttribute("data-state")).toBe("indeterminate");
});
it("shows percentage as aria-valuetext by default", () => {
render(() => <Progress value={50} />);
expect(screen.getByRole("progressbar").getAttribute("aria-valuetext")).toBe("50%");
});
it("uses custom getValueLabel", () => {
render(() => (
<Progress value={1} max={10} getValueLabel={(v, m) => `${v} of ${m}`} />
));
expect(screen.getByRole("progressbar").getAttribute("aria-valuetext")).toBe("1 of 10");
});
it("data-state=complete when value provided", () => {
render(() => <Progress value={75} />);
expect(screen.getByRole("progressbar").getAttribute("data-state")).toBe("complete");
});
});