Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ export const HasProgressbarRole: Story = {
},
};

/**
* Out-of-range `value` is clamped to `[0, max]` before the arc and `aria-valuenow` are derived, so
* the two never disagree. Tagged out of the sidebar/docs/manifest while still running under
* `test`.
*/
export const ClampsOutOfRange: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { value: 150, "aria-label": "Overshoot" },
play: async ({ canvas }) => {
const ring = canvas.getByRole("progressbar", { name: "Overshoot" });
await expect(ring).toHaveAttribute("aria-valuenow", "100");
},
};

/** `value={null}` is indeterminate: a fixed quarter arc spins with no `aria-valuenow`. */
export const Indeterminate: Story = {
args: { value: null, magnitude: "md", tone: "brand", "aria-label": "Syncing" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const RING_STROKE = 2;

export type CircularProgressProps = Omit<
BaseProgress.Root.Props,
"className" | "style" | "value"
"className" | "style" | "value" | "render"
> & {
/**
* Completion from 0 to `max` (default 100). `null` = indeterminate: a fixed quarter arc spins
Expand All @@ -47,17 +47,20 @@ export function CircularProgress({ value, magnitude, tone, ...props }: CircularP
const { box, radius } = RING_GEOMETRY[magnitude];
const circumference = 2 * Math.PI * radius;
const max = props.max ?? 100;
// Clamp once so the arc and `aria-valuenow` never disagree for out-of-range input. While
// indeterminate a fixed quarter arc shows; the svg part spins it off `data-indeterminate`.
const clampedValue = value == null ? null : Math.min(Math.max(value, 0), max);
const fraction = clampedValue == null ? 0.25 : max > 0 ? clampedValue / max : 0;
const min = props.min ?? 0;
const span = max - min;
// Clamp once so the arc and `aria-valuenow` never disagree for out-of-range input, and derive the
// fraction from both bounds so a non-zero `min` maps correctly. While indeterminate a fixed
// quarter arc shows; the svg part spins it off `data-indeterminate`.
const clampedValue = value == null ? null : Math.min(Math.max(value, min), max);
const fraction = clampedValue == null ? 0.25 : span > 0 ? (clampedValue - min) / span : 0;
const dashOffset = circumference * (1 - fraction);
const center = box / 2;
return (
<BaseProgress.Root
value={clampedValue}
render={<CircularProgressElement magnitude={magnitude} />}
{...props}
render={<CircularProgressElement magnitude={magnitude} />}
>
<CircularProgressSvg viewBox={`0 0 ${box} ${box}`}>
<CircularProgressTrack cx={center} cy={center} r={radius} strokeWidth={RING_STROKE} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { expect, waitFor } from "storybook/test";

import {
LinearProgressIndicator,
LinearProgressLabel,
LinearProgressTrack,
LinearProgressValue,
} from "../../elements/linear-progress";
Expand All @@ -15,7 +16,12 @@ const TONES: LinearProgressTone[] = ["brand", "success", "warning", "danger"];
const meta = {
title: "Components/LinearProgress",
component: LinearProgress,
subcomponents: { LinearProgressTrack, LinearProgressIndicator, LinearProgressValue },
subcomponents: {
LinearProgressLabel,
LinearProgressTrack,
LinearProgressIndicator,
LinearProgressValue,
},
args: { value: 60, magnitude: "md", tone: "brand", "aria-label": "Upload progress" },
// The bar has no intrinsic width (`w-full` root, `flex-1 min-w-0` track), so a bare render
// collapses to 0px under the centered layout — give every story a real width to fill.
Expand All @@ -29,7 +35,10 @@ const meta = {
} satisfies Meta<typeof LinearProgress>;

export default meta;
type Story = StoryObj<typeof meta>;
// Typed against the component (not `typeof meta`): the props are a discriminated union on
// `label`/`aria-label`, which `StoryObj<typeof meta>` collapses to `never`. `StoryObj<typeof
// LinearProgress>` keeps per-story args as a `Partial` of the union so stories still type-check.
type Story = StoryObj<typeof LinearProgress>;

export const Default: Story = {};

Expand Down Expand Up @@ -106,5 +115,11 @@ export const HasProgressbarRole: Story = {

/** A visible text label before the track — `label` also names the bar for assistive tech. */
export const WithLabel: Story = {
args: { value: 60, magnitude: "md", tone: "brand", label: "Uploading attachments" },
args: {
value: 60,
magnitude: "md",
tone: "brand",
label: "Uploading attachments",
"aria-label": undefined,
},
};
36 changes: 30 additions & 6 deletions packages/propel/src/components/linear-progress/linear-progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
export type LinearProgressMagnitude = NonNullable<LinearProgressTrackProps["magnitude"]>;
export type LinearProgressTone = NonNullable<LinearProgressIndicatorProps["tone"]>;

export type LinearProgressProps = Omit<BaseProgress.Root.Props, "className" | "style" | "value"> & {
type LinearProgressOwnProps = Omit<
BaseProgress.Root.Props,
"className" | "style" | "value" | "render" | "aria-label"
> & {
/**
* Completion from 0 to `max` (default 100). `null` = indeterminate (animated fill,
* `aria-valuenow` unset).
Expand All @@ -25,12 +28,28 @@ export type LinearProgressProps = Omit<BaseProgress.Root.Props, "className" | "s
tone: LinearProgressTone;
/** Show the trailing percentage label. @default true */
showValue?: boolean;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-label is now optional while label is optional too, so an unnamed progressbar type-checks and renders. Could the props require at least one accessible name (label or aria-label) to preserve the a11y invariant?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. LinearProgressProps is now a discriminated union — label or aria-label is required, so an unnamed progressbar is a compile error:

export type LinearProgressProps = LinearProgressOwnProps &
  ({ label: string; "aria-label"?: string } | { label?: undefined; "aria-label": string });

Labeled → named by the visible label; no label → aria-label required. (Same pattern as Badge.)

/** Visible text label before the track — Base UI's `Progress.Label` (also names the bar). */
label?: string;
/** Accessible name (required — the visible % is not a substitute). */
"aria-label": string;
};

/**
* The bar always has an accessible name: pass a visible `label` (names it via Base UI's
* `Progress.Label`) or an `aria-label` — the union makes omitting both a type error rather than a
* silently unnamed progressbar.
*/
export type LinearProgressProps = LinearProgressOwnProps &
(
| {
/** Visible text label before the track — Base UI's `Progress.Label` (also names the bar). */
label: string;
/** Accessible name. Optional when `label` is set — the visible label already names the bar. */
"aria-label"?: string;
}
| {
label?: undefined;
/** Accessible name (required when there is no visible `label`). */
"aria-label": string;
}
);

/**
* A horizontal determinate/indeterminate progress bar with an optional trailing `%` label. Drive it
* with `value` (0–`max`); the fill and `aria-valuenow` follow. For a ring, use `CircularProgress`.
Expand All @@ -45,8 +64,13 @@ export function LinearProgress({
label,
...props
}: LinearProgressProps) {
const max = props.max ?? 100;
const min = props.min ?? 0;
// Clamp out-of-range input to `[min, max]` before Base UI derives the fill, `aria-valuenow`, and
// the `%` label, so they never disagree (e.g. `value={150}` reads 100%, not a 150% overflow).
const clampedValue = value == null ? null : Math.min(Math.max(value, min), max);
return (
<BaseProgress.Root value={value} {...props} render={<LinearProgressElement />}>
<BaseProgress.Root value={clampedValue} {...props} render={<LinearProgressElement />}>
{label != null ? (
<BaseProgress.Label render={<LinearProgressLabel />}>{label}</BaseProgress.Label>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
circularProgressIndicatorVariants,
} from "./variants";

/** Props for {@link CircularProgressIndicator}. */
export type CircularProgressIndicatorProps = Omit<
useRender.ComponentProps<"circle">,
"className" | "style"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useRender } from "@base-ui/react/use-render";

import { circularProgressSvgVariants } from "./variants";

/** Props for {@link CircularProgressSvg}. */
export type CircularProgressSvgProps = Omit<useRender.ComponentProps<"svg">, "className" | "style">;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useRender } from "@base-ui/react/use-render";

import { circularProgressTrackVariants } from "./variants";

/** Props for {@link CircularProgressTrack}. */
export type CircularProgressTrackProps = Omit<
useRender.ComponentProps<"circle">,
"className" | "style"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useRender } from "@base-ui/react/use-render";

import { type CircularProgressVariantProps, circularProgressVariants } from "./variants";

/** Props for {@link CircularProgress}. */
export type CircularProgressProps = Omit<useRender.ComponentProps<"div">, "className" | "style"> &
CircularProgressVariantProps;

Expand Down
3 changes: 2 additions & 1 deletion packages/propel/src/elements/linear-progress/variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export const linearProgressIndicatorVariants = cva(
export const linearProgressLabelVariants = cva("text-13 font-medium text-secondary");

// The percentage is a neutral readout (matching the label), not a toned signal — the semantic color
// lives on the fill bar. Neutral also avoids low-contrast amber/green readouts on a neutral surface.
// lives on the fill bar. Neutral also keeps the small 12px number AA-legible: the warning amber
// text token lands at 4.49:1 on the neutral surface (below WCAG AA), so tinting the % is not viable.
export const linearProgressValueVariants = cva("text-12 font-medium text-secondary tabular-nums");

export type LinearProgressTrackVariantProps = StrictVariantProps<
Expand Down