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:
parent
041217ca33
commit
21a7991562
2
packages/core/src/components/progress/index.ts
Normal file
2
packages/core/src/components/progress/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Progress } from "./progress";
|
||||||
|
export type { ProgressProps } from "./progress";
|
||||||
64
packages/core/src/components/progress/progress.tsx
Normal file
64
packages/core/src/components/progress/progress.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
packages/core/tests/components/progress/progress.test.tsx
Normal file
51
packages/core/tests/components/progress/progress.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user