diff --git a/packages/propel/src/components/circular-progress/circular-progress.stories.tsx b/packages/propel/src/components/circular-progress/circular-progress.stories.tsx index 21b87d2a..51aff667 100644 --- a/packages/propel/src/components/circular-progress/circular-progress.stories.tsx +++ b/packages/propel/src/components/circular-progress/circular-progress.stories.tsx @@ -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" }, diff --git a/packages/propel/src/components/circular-progress/circular-progress.tsx b/packages/propel/src/components/circular-progress/circular-progress.tsx index 2525e8f1..57b22330 100644 --- a/packages/propel/src/components/circular-progress/circular-progress.tsx +++ b/packages/propel/src/components/circular-progress/circular-progress.tsx @@ -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 @@ -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 ( } {...props} + render={} > diff --git a/packages/propel/src/components/linear-progress/linear-progress.stories.tsx b/packages/propel/src/components/linear-progress/linear-progress.stories.tsx index 7d02dd27..4e3567b8 100644 --- a/packages/propel/src/components/linear-progress/linear-progress.stories.tsx +++ b/packages/propel/src/components/linear-progress/linear-progress.stories.tsx @@ -4,6 +4,7 @@ import { expect, waitFor } from "storybook/test"; import { LinearProgressIndicator, + LinearProgressLabel, LinearProgressTrack, LinearProgressValue, } from "../../elements/linear-progress"; @@ -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. @@ -29,7 +35,10 @@ const meta = { } satisfies Meta; export default meta; -type Story = StoryObj; +// Typed against the component (not `typeof meta`): the props are a discriminated union on +// `label`/`aria-label`, which `StoryObj` collapses to `never`. `StoryObj` keeps per-story args as a `Partial` of the union so stories still type-check. +type Story = StoryObj; export const Default: Story = {}; @@ -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, + }, }; diff --git a/packages/propel/src/components/linear-progress/linear-progress.tsx b/packages/propel/src/components/linear-progress/linear-progress.tsx index da06ecf3..823448c4 100644 --- a/packages/propel/src/components/linear-progress/linear-progress.tsx +++ b/packages/propel/src/components/linear-progress/linear-progress.tsx @@ -13,7 +13,10 @@ import { export type LinearProgressMagnitude = NonNullable; export type LinearProgressTone = NonNullable; -export type LinearProgressProps = Omit & { +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). @@ -25,12 +28,28 @@ export type LinearProgressProps = Omit}> + }> {label != null ? ( }>{label} ) : null} diff --git a/packages/propel/src/elements/circular-progress/circular-progress-indicator.tsx b/packages/propel/src/elements/circular-progress/circular-progress-indicator.tsx index b926607e..6b9f07b4 100644 --- a/packages/propel/src/elements/circular-progress/circular-progress-indicator.tsx +++ b/packages/propel/src/elements/circular-progress/circular-progress-indicator.tsx @@ -6,7 +6,6 @@ import { circularProgressIndicatorVariants, } from "./variants"; -/** Props for {@link CircularProgressIndicator}. */ export type CircularProgressIndicatorProps = Omit< useRender.ComponentProps<"circle">, "className" | "style" diff --git a/packages/propel/src/elements/circular-progress/circular-progress-svg.tsx b/packages/propel/src/elements/circular-progress/circular-progress-svg.tsx index c2133586..02322c98 100644 --- a/packages/propel/src/elements/circular-progress/circular-progress-svg.tsx +++ b/packages/propel/src/elements/circular-progress/circular-progress-svg.tsx @@ -3,7 +3,6 @@ import { useRender } from "@base-ui/react/use-render"; import { circularProgressSvgVariants } from "./variants"; -/** Props for {@link CircularProgressSvg}. */ export type CircularProgressSvgProps = Omit, "className" | "style">; /** diff --git a/packages/propel/src/elements/circular-progress/circular-progress-track.tsx b/packages/propel/src/elements/circular-progress/circular-progress-track.tsx index 894a76f1..90ab34b3 100644 --- a/packages/propel/src/elements/circular-progress/circular-progress-track.tsx +++ b/packages/propel/src/elements/circular-progress/circular-progress-track.tsx @@ -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" diff --git a/packages/propel/src/elements/circular-progress/circular-progress.tsx b/packages/propel/src/elements/circular-progress/circular-progress.tsx index 62d69b52..c7bcbe7d 100644 --- a/packages/propel/src/elements/circular-progress/circular-progress.tsx +++ b/packages/propel/src/elements/circular-progress/circular-progress.tsx @@ -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, "className" | "style"> & CircularProgressVariantProps; diff --git a/packages/propel/src/elements/linear-progress/variants.ts b/packages/propel/src/elements/linear-progress/variants.ts index 974fe83f..2b257dce 100644 --- a/packages/propel/src/elements/linear-progress/variants.ts +++ b/packages/propel/src/elements/linear-progress/variants.ts @@ -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<