From 21a7991562f233150e8167e14fb759bd0b6fc56f Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 07:25:07 +0700 Subject: [PATCH] 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 --- .../core/src/components/progress/index.ts | 2 + .../core/src/components/progress/progress.tsx | 64 +++++++++++++++++++ .../components/progress/progress.test.tsx | 51 +++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 packages/core/src/components/progress/index.ts create mode 100644 packages/core/src/components/progress/progress.tsx create mode 100644 packages/core/tests/components/progress/progress.test.tsx diff --git a/packages/core/src/components/progress/index.ts b/packages/core/src/components/progress/index.ts new file mode 100644 index 0000000..26987b8 --- /dev/null +++ b/packages/core/src/components/progress/index.ts @@ -0,0 +1,2 @@ +export { Progress } from "./progress"; +export type { ProgressProps } from "./progress"; diff --git a/packages/core/src/components/progress/progress.tsx b/packages/core/src/components/progress/progress.tsx new file mode 100644 index 0000000..7ce7f08 --- /dev/null +++ b/packages/core/src/components/progress/progress.tsx @@ -0,0 +1,64 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +export interface ProgressProps extends JSX.HTMLAttributes { + /** 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 ( +
+ ); +} diff --git a/packages/core/tests/components/progress/progress.test.tsx b/packages/core/tests/components/progress/progress.test.tsx new file mode 100644 index 0000000..df7ec8b --- /dev/null +++ b/packages/core/tests/components/progress/progress.test.tsx @@ -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(() => ); + expect(screen.getByRole("progressbar")).toBeTruthy(); + }); + + it("sets aria-valuenow", () => { + render(() => ); + expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe("50"); + }); + + it("sets aria-valuemin=0 and aria-valuemax=100 by default", () => { + render(() => ); + const pb = screen.getByRole("progressbar"); + expect(pb.getAttribute("aria-valuemin")).toBe("0"); + expect(pb.getAttribute("aria-valuemax")).toBe("100"); + }); + + it("uses custom max", () => { + render(() => ); + expect(screen.getByRole("progressbar").getAttribute("aria-valuemax")).toBe("10"); + }); + + it("is indeterminate when value is null", () => { + render(() => ); + 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(() => ); + expect(screen.getByRole("progressbar").getAttribute("aria-valuetext")).toBe("50%"); + }); + + it("uses custom getValueLabel", () => { + render(() => ( + `${v} of ${m}`} /> + )); + expect(screen.getByRole("progressbar").getAttribute("aria-valuetext")).toBe("1 of 10"); + }); + + it("data-state=complete when value provided", () => { + render(() => ); + expect(screen.getByRole("progressbar").getAttribute("data-state")).toBe("complete"); + }); +});