From f603159b120dec95363b7f2992cf9a961e4d500c Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:06:06 +0700 Subject: [PATCH 1/3] Align progress to architecture goals: tone, indeterminate, cva MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The designer's spec (issue #145) identified two adjustable axes that the component didn't expose: color/sentiment and indeterminate mode. Both are now first-class required props. Spec alignment: - Always the same: horizontal bar shape, track bg, fill direction, border-radius, track height per magnitude, smooth transition, fill capped at 100% — all baked in via cva base classes. - Depends (adjustable): value, indeterminate mode, tone (brand/success/warning/danger), magnitude, and showValue — all exposed as props (tone and magnitude required, no defaultVariants). Architecture changes: - variants.ts: add `tone` variant to progressIndicatorVariants (bg-*-primary), progressValueVariants (text-*-primary), and ringVariants ([--progress-fill:var(--bg-*-primary)]). The circle's arc now reads --progress-fill so all color logic stays in cva. - Indeterminate: add progress-indeterminate @keyframes to animations.css; indicator targets data-indeterminate: to animate a sliding fill and suppress the width transition. - ProgressIndicator/ProgressValue/ProgressCircle: tone is now a required prop (no defaultVariants). - components/progress: thread tone through; value widened to number | null for indeterminate support on the linear variant. - Stories: add Tones and Indeterminate stories; update all render functions to pass explicit tone. --- .../propel/src/components/progress/index.tsx | 1 + .../components/progress/progress.stories.tsx | 145 ++++++++++++++++-- .../src/components/progress/progress.tsx | 48 ++++-- packages/propel/src/styles/animations.css | 14 ++ .../src/ui/progress/progress-circle.tsx | 32 ++-- .../src/ui/progress/progress-indicator.tsx | 10 +- .../propel/src/ui/progress/progress-value.tsx | 13 +- .../src/ui/progress/progress.stories.tsx | 14 +- packages/propel/src/ui/progress/variants.ts | 53 ++++++- 9 files changed, 270 insertions(+), 60 deletions(-) diff --git a/packages/propel/src/components/progress/index.tsx b/packages/propel/src/components/progress/index.tsx index f06ae21f..c793eea6 100644 --- a/packages/propel/src/components/progress/index.tsx +++ b/packages/propel/src/components/progress/index.tsx @@ -2,5 +2,6 @@ export { Progress, type ProgressProps, type ProgressMagnitude, + type ProgressTone, type ProgressVariant, } from "./progress"; diff --git a/packages/propel/src/components/progress/progress.stories.tsx b/packages/propel/src/components/progress/progress.stories.tsx index aabb5bd9..b24e545e 100644 --- a/packages/propel/src/components/progress/progress.stories.tsx +++ b/packages/propel/src/components/progress/progress.stories.tsx @@ -6,7 +6,13 @@ import { Progress } from "./index"; const meta = { title: "Components/Progress", component: Progress, - args: { variant: "linear", value: 32, magnitude: "md", "aria-label": "Upload progress" }, + args: { + variant: "linear", + value: 32, + magnitude: "md", + tone: "brand", + "aria-label": "Upload progress", + }, // Give the bar a width to fill (it grows to its container). decorators: [ (Story) => ( @@ -34,8 +40,20 @@ export const Magnitudes: Story = { parameters: { controls: { disable: true } }, render: () => (
- - + +
), }; @@ -45,24 +63,83 @@ export const Values: Story = { parameters: { controls: { disable: true } }, render: () => (
- - - + + +
), }; +/** All four sentiment tones (`brand` / `success` / `warning` / `danger`). */ +export const Tones: Story = { + parameters: { controls: { disable: true } }, + render: () => ( +
+ + + + +
+ ), +}; + +/** + * Indeterminate mode — pass `value={null}` when the end time is unknown (e.g. an in-flight network + * request). The bar slides a shorter fill back and forth. `showValue` is automatically hidden since + * there is no percentage to display. + */ +export const Indeterminate: Story = { + args: { + variant: "linear", + value: null, + magnitude: "md", + tone: "brand", + showValue: false, + "aria-label": "Loading", + }, +}; + /** Hide the trailing label with `showValue={false}` (e.g. a slim inline track). */ export const WithoutLabel: Story = { - args: { variant: "linear", value: 64, magnitude: "md", showValue: false }, + args: { variant: "linear", value: 64, magnitude: "md", tone: "brand", showValue: false }, }; /** - * `variant="circular"` is a small determinate ring — a gray track plus an accent arc proportional - * to the value, with no label. The arc starts at 12 o'clock and sweeps clockwise. + * `variant="circular"` is a small determinate ring — a gray track plus a toned arc proportional to + * the value, with no label. The arc starts at 12 o'clock and sweeps clockwise. */ export const Circular: Story = { - args: { variant: "circular", value: 32, magnitude: "md", "aria-label": "Sync progress" }, + args: { + variant: "circular", + value: 32, + magnitude: "md", + tone: "brand", + "aria-label": "Sync progress", + }, // The ring sizes itself; the meta's 80-wide wrapper isn't needed. }; @@ -71,12 +148,48 @@ export const CircularMagnitudes: Story = { parameters: { controls: { disable: true } }, render: () => (
- - - - - - + + + + + +
), }; diff --git a/packages/propel/src/components/progress/progress.tsx b/packages/propel/src/components/progress/progress.tsx index c1b509b5..b398caba 100644 --- a/packages/propel/src/components/progress/progress.tsx +++ b/packages/propel/src/components/progress/progress.tsx @@ -6,30 +6,39 @@ import { ProgressIndicator, ProgressTrack, ProgressValue, + type ProgressIndicatorProps, type ProgressTrackProps, } from "../../ui/progress"; // The Figma "Progress" component has two variants: -// - linear (node 1990-51): a pill-shaped track (`layer-3-selected`) with an -// accent-filled indicator and an optional trailing percentage label -// (`text/accent/primary`, 12px medium). `magnitude` only changes the track -// thickness — `sm` 5px, `md` 8px. +// - linear (node 1990-51): a pill-shaped track (`layer-3-selected`) with a toned indicator +// and an optional trailing percentage label (12px medium). `magnitude` only changes the +// track thickness — `sm` 5px, `md` 8px. // - circular (node 5736-3457): a small determinate ring — a subtle track circle -// (`layer-3-selected`, the same surface the linear track uses) plus an accent arc -// (`background/accent/primary`) proportional to the value, with rounded caps and no -// label. `magnitude` changes the diameter — `sm` 16px, `md` 20px. +// (`layer-3-selected`) plus a toned arc proportional to the value, with rounded caps and +// no label. `magnitude` changes the diameter — `sm` 16px, `md` 20px. // Both are built on Base UI `Progress`, which owns the `progressbar` role + // `aria-valuenow` for us. export type ProgressMagnitude = NonNullable; +export type ProgressTone = NonNullable; export type ProgressVariant = "linear" | "circular"; export type ProgressProps = Omit & { /** `linear` = a horizontal bar. `circular` = a determinate ring. */ variant: ProgressVariant; - /** Completion from 0 to `max` (default max 100). */ - value: number; + /** + * Completion from 0 to `max` (default max 100). Pass `null` for indeterminate mode — the bar + * animates a sliding fill and `aria-valuenow` is unset. Only applies to `variant="linear"`; + * `circular` always requires a number. + */ + value: number | null; /** `linear`: track thickness (`sm` 5px / `md` 8px). `circular`: diameter (`sm` 16px / `md` 20px). */ magnitude: ProgressMagnitude; + /** + * Fill color for the indicator (and arc). Maps to semantic signal: `brand` is the default accent, + * `success`/`warning`/`danger` encode task outcome. + */ + tone: ProgressTone; /** * Show the trailing percentage label. Only applies to `variant="linear"` — the circular rings are * too small for a label, so this is ignored when `circular`. @@ -53,13 +62,26 @@ export type ProgressProps = Omit; + // The circular ring always needs a concrete value to compute its arc geometry; indeterminate + // mode is a linear-only feature. Fall back to 0 for null so the ring renders empty rather + // than throwing. + return ; } return ( @@ -67,10 +89,10 @@ export function Progress({ variant, value, magnitude, showValue = true, ...props {/* Base UI sets the indicator's `width` (and `inset-inline-start: 0`) from the value; the ui primitive owns its fill, pill radius, and the fill transition. */} - + {showValue ? ( - + {(_, currentValue) => (currentValue == null ? "" : `${Math.round(currentValue)}%`)} ) : null} diff --git a/packages/propel/src/styles/animations.css b/packages/propel/src/styles/animations.css index 95f679d8..69346926 100644 --- a/packages/propel/src/styles/animations.css +++ b/packages/propel/src/styles/animations.css @@ -174,6 +174,20 @@ } } + /* progress indeterminate: slide a 1/3-width bar across the track and back */ + --animate-progress-indeterminate: progress-indeterminate 1.4s ease-in-out infinite; + @keyframes progress-indeterminate { + 0% { + inset-inline-start: -33.333%; + } + 50% { + inset-inline-start: 100%; + } + 100% { + inset-inline-start: -33.333%; + } + } + /* this '--animate-notification-bell-ring' used for workspace notifications for reminders */ --animate-notification-bell-ring: notification-bell-ring 2.2s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; diff --git a/packages/propel/src/ui/progress/progress-circle.tsx b/packages/propel/src/ui/progress/progress-circle.tsx index 390db75e..948264f8 100644 --- a/packages/propel/src/ui/progress/progress-circle.tsx +++ b/packages/propel/src/ui/progress/progress-circle.tsx @@ -14,6 +14,7 @@ const RING_GEOMETRY = { const RING_STROKE = 2; type RingMagnitude = NonNullable["magnitude"]>; +type RingTone = NonNullable["tone"]>; /** Props for {@link ProgressCircle}. */ export type ProgressCircleProps = Omit & { @@ -21,17 +22,20 @@ export type ProgressCircleProps = Omit
- - + +
), }; diff --git a/packages/propel/src/ui/progress/variants.ts b/packages/propel/src/ui/progress/variants.ts index c9253d04..2ec7d088 100644 --- a/packages/propel/src/ui/progress/variants.ts +++ b/packages/propel/src/ui/progress/variants.ts @@ -1,10 +1,48 @@ import { cva } from "class-variance-authority"; +/** + * Indicator fill variants. `tone` drives the fill color of the bar (and the arc in the circular + * ring). All other indicator styles — pill radius, transition, inset — are always the same. + * + * Indeterminate state: Base UI sets `data-indeterminate` on its Root and propagates it to the + * Indicator; we target it here with a slide-pulse animation so the bar communicates an + * unknown-duration operation without a fixed value. + */ export const progressIndicatorVariants = cva( - "absolute inset-y-0 rounded-full bg-accent-primary transition-[width] duration-300 ease-out", + [ + "absolute inset-y-0 rounded-full transition-[width] duration-300 ease-out", + // Indeterminate: slide a shorter bar back and forth across the track. + // Suppress the determinate width transition so it doesn't interfere. + "data-indeterminate:w-1/3 data-indeterminate:animate-progress-indeterminate data-indeterminate:transition-none", + ], + { + variants: { + tone: { + brand: "bg-accent-primary", + success: "bg-success-primary", + warning: "bg-warning-primary", + danger: "bg-danger-primary", + }, + }, + }, ); + export const progressLabelVariants = cva("text-13 font-medium text-secondary"); -export const progressValueVariants = cva("text-12 font-medium text-accent-primary tabular-nums"); + +/** + * Value text variants. `tone` keeps the percentage in the same hue family as the fill, so the + * number reads as part of the same semantic signal. + */ +export const progressValueVariants = cva("text-12 font-medium tabular-nums", { + variants: { + tone: { + brand: "text-accent-primary", + success: "text-success-primary", + warning: "text-warning-primary", + danger: "text-danger-primary", + }, + }, +}); export const rootVariants = cva("", { variants: { @@ -26,11 +64,22 @@ export const trackVariants = cva( }, ); +/** + * Circular ring container variants. `tone` sets the `--progress-fill` CSS custom property which the + * indicator arc circle reads via `[stroke:var(--progress-fill)]`, keeping all className composition + * inside cva. + */ export const ringVariants = cva("shrink-0", { variants: { magnitude: { sm: "size-4", md: "size-5", }, + tone: { + brand: "[--progress-fill:var(--bg-accent-primary)]", + success: "[--progress-fill:var(--bg-success-primary)]", + warning: "[--progress-fill:var(--bg-warning-primary)]", + danger: "[--progress-fill:var(--bg-danger-primary)]", + }, }, }); From aa8800a1b9c2bf53d56eea7d51fceec7a8585825 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:34:05 +0700 Subject: [PATCH 2/3] Extract progress circular ring into single-element anatomy parts The circular ring baked its whole SVG (root > svg > track circle + arc circle) into one ProgressCircle part with inline class strings, so the ring had no composable anatomy and carried styling outside cva. Split it into single-element parts mirroring the linear bar: ProgressCircle (the styled Progress.Root, owns the box magnitude + progressbar a11y), ProgressCircleSvg (the aria-hidden viewport), ProgressCircleTrack (the subtle full ring), and ProgressCircleIndicator (the toned arc; tone drives the stroke). Each renders exactly one element and pulls every class into cva in variants.ts; the geometry (radius, dash offset) stays in the components tier as value-model SVG attributes. The components Progress now composes these parts instead of delegating to a monolithic ring. Make the percentage readout neutral (text-secondary, matching the label) instead of toned: amber-800/green readouts on a neutral surface fail WCAG AA (4.4:1), and the spec puts the semantic color on the fill, not the number. Drop the now-unused tone prop from ProgressValue. Pass the required tone to the toast's progress bar. --- .../src/components/progress/progress.tsx | 52 ++++++++++-- .../propel/src/components/toast/toast.tsx | 1 + packages/propel/src/ui/progress/index.tsx | 6 ++ .../ui/progress/progress-circle-indicator.tsx | 20 +++++ .../src/ui/progress/progress-circle-svg.tsx | 19 +++++ .../src/ui/progress/progress-circle-track.tsx | 18 ++++ .../src/ui/progress/progress-circle.tsx | 84 ++----------------- .../propel/src/ui/progress/progress-value.tsx | 12 ++- .../src/ui/progress/progress.stories.tsx | 60 +++++++++---- packages/propel/src/ui/progress/variants.ts | 64 ++++++++------ 10 files changed, 206 insertions(+), 130 deletions(-) create mode 100644 packages/propel/src/ui/progress/progress-circle-indicator.tsx create mode 100644 packages/propel/src/ui/progress/progress-circle-svg.tsx create mode 100644 packages/propel/src/ui/progress/progress-circle-track.tsx diff --git a/packages/propel/src/components/progress/progress.tsx b/packages/propel/src/components/progress/progress.tsx index b398caba..a84d2cce 100644 --- a/packages/propel/src/components/progress/progress.tsx +++ b/packages/propel/src/components/progress/progress.tsx @@ -3,6 +3,9 @@ import { Progress as BaseProgress } from "@base-ui/react/progress"; import { Progress as ProgressRoot, ProgressCircle, + ProgressCircleIndicator, + ProgressCircleSvg, + ProgressCircleTrack, ProgressIndicator, ProgressTrack, ProgressValue, @@ -23,6 +26,17 @@ export type ProgressMagnitude = NonNullable; export type ProgressTone = NonNullable; export type ProgressVariant = "linear" | "circular"; +// Circle geometry per magnitude (value model, not styling). The Figma circular variant +// (nodes 5736-3457 / 5736-3460) is a 16px (sm) / 20px (md) box holding a 2px-stroke ring: +// a 14px circle (sm) or 18px circle (md). The centerline radius is therefore +// (diameter - stroke) / 2 -> 6 (sm) / 8 (md). The viewBox matches the box so 1 SVG user +// unit == 1px. +const RING_GEOMETRY: Record = { + sm: { box: 16, radius: 6 }, + md: { box: 20, radius: 8 }, +}; +const RING_STROKE = 2; + export type ProgressProps = Omit & { /** `linear` = a horizontal bar. `circular` = a determinate ring. */ variant: ProgressVariant; @@ -66,8 +80,9 @@ export type ProgressProps = Omit; + const { box, radius } = RING_GEOMETRY[magnitude]; + const circumference = 2 * Math.PI * radius; + const max = props.max ?? 100; + // Clamp once and feed the same value to the SVG arc and the Root, so the arc and + // `aria-valuenow` never disagree for out-of-range input. Indeterminate mode is a + // linear-only feature; fall back to 0 for null so the ring renders empty. + const clampedValue = Math.min(Math.max(value ?? 0, 0), max); + const fraction = max > 0 ? clampedValue / max : 0; + const dashOffset = circumference * (1 - fraction); + const center = box / 2; + return ( + + + + + + + ); } return ( @@ -92,7 +130,7 @@ export function Progress({ {showValue ? ( - + {(_, currentValue) => (currentValue == null ? "" : `${Math.round(currentValue)}%`)} ) : null} diff --git a/packages/propel/src/components/toast/toast.tsx b/packages/propel/src/components/toast/toast.tsx index 24844b66..edddb009 100644 --- a/packages/propel/src/components/toast/toast.tsx +++ b/packages/propel/src/components/toast/toast.tsx @@ -113,6 +113,7 @@ export function Toast({ toast, ...props }: ToastProps) { variant="linear" value={data.progress} magnitude="sm" + tone="brand" aria-label={typeof toast.title === "string" && toast.title ? toast.title : "Progress"} /> ) : null} diff --git a/packages/propel/src/ui/progress/index.tsx b/packages/propel/src/ui/progress/index.tsx index c319b7b9..171ca428 100644 --- a/packages/propel/src/ui/progress/index.tsx +++ b/packages/propel/src/ui/progress/index.tsx @@ -1,5 +1,11 @@ export * from "./progress"; export { ProgressCircle, type ProgressCircleProps } from "./progress-circle"; +export { + ProgressCircleIndicator, + type ProgressCircleIndicatorProps, +} from "./progress-circle-indicator"; +export { ProgressCircleSvg, type ProgressCircleSvgProps } from "./progress-circle-svg"; +export { ProgressCircleTrack, type ProgressCircleTrackProps } from "./progress-circle-track"; export { ProgressIndicator, type ProgressIndicatorProps } from "./progress-indicator"; export { ProgressLabel, type ProgressLabelProps } from "./progress-label"; export { ProgressTrack, type ProgressTrackProps } from "./progress-track"; diff --git a/packages/propel/src/ui/progress/progress-circle-indicator.tsx b/packages/propel/src/ui/progress/progress-circle-indicator.tsx new file mode 100644 index 00000000..33ad8c02 --- /dev/null +++ b/packages/propel/src/ui/progress/progress-circle-indicator.tsx @@ -0,0 +1,20 @@ +import { type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { circleIndicatorVariants } from "./variants"; + +/** Props for {@link ProgressCircleIndicator}. */ +export type ProgressCircleIndicatorProps = Omit< + React.ComponentPropsWithoutRef<"circle">, + "className" | "style" +> & + Required, "tone">>; + +/** + * The toned arc proportional to the value (the circular analogue of `ProgressIndicator`). `tone` + * drives the stroke color. Pass the geometry (`cx` / `cy` / `r` / `strokeWidth` / `strokeDasharray` + * / `strokeDashoffset` / `strokeLinecap`) as SVG attributes. + */ +export function ProgressCircleIndicator({ tone, ...props }: ProgressCircleIndicatorProps) { + return ; +} diff --git a/packages/propel/src/ui/progress/progress-circle-svg.tsx b/packages/propel/src/ui/progress/progress-circle-svg.tsx new file mode 100644 index 00000000..b247f313 --- /dev/null +++ b/packages/propel/src/ui/progress/progress-circle-svg.tsx @@ -0,0 +1,19 @@ +import type * as React from "react"; + +import { circleSvgVariants } from "./variants"; + +/** Props for {@link ProgressCircleSvg}. */ +export type ProgressCircleSvgProps = Omit< + React.ComponentPropsWithoutRef<"svg">, + "className" | "style" +>; + +/** + * The SVG viewport of the circular ring. Fills its `ProgressCircle` box and holds the + * `ProgressCircleTrack` + `ProgressCircleIndicator` circles. Decorative (the `ProgressCircle` root + * carries the `progressbar` a11y), so it is `aria-hidden`. Pass a `viewBox` matching the ring box + * so one SVG user unit equals one pixel. + */ +export function ProgressCircleSvg(props: ProgressCircleSvgProps) { + return ; +} diff --git a/packages/propel/src/ui/progress/progress-circle-track.tsx b/packages/propel/src/ui/progress/progress-circle-track.tsx new file mode 100644 index 00000000..412b9d07 --- /dev/null +++ b/packages/propel/src/ui/progress/progress-circle-track.tsx @@ -0,0 +1,18 @@ +import type * as React from "react"; + +import { circleTrackVariants } from "./variants"; + +/** Props for {@link ProgressCircleTrack}. */ +export type ProgressCircleTrackProps = Omit< + React.ComponentPropsWithoutRef<"circle">, + "className" | "style" +>; + +/** + * The full subtle ring behind the arc (the circular analogue of `ProgressTrack`). Pass the geometry + * (`cx` / `cy` / `r` / `strokeWidth`) as SVG attributes; the low-emphasis stroke color is baked + * in. + */ +export function ProgressCircleTrack(props: ProgressCircleTrackProps) { + return ; +} diff --git a/packages/propel/src/ui/progress/progress-circle.tsx b/packages/propel/src/ui/progress/progress-circle.tsx index 948264f8..2a7439ec 100644 --- a/packages/propel/src/ui/progress/progress-circle.tsx +++ b/packages/propel/src/ui/progress/progress-circle.tsx @@ -1,85 +1,17 @@ import { Progress as BaseProgress } from "@base-ui/react/progress"; import { type VariantProps } from "class-variance-authority"; -import { ringVariants } from "./variants"; - -// Circle geometry per magnitude. The Figma circular variant (nodes 5736-3457 / 5736-3460) -// is a 16px (sm) / 20px (md) box holding a 2px-stroke ring: a 14px circle (sm) or 18px -// circle (md). The centerline radius is therefore (diameter - stroke) / 2 -> 6 (sm) / 8 -// (md). The viewBox matches the box so 1 SVG user unit == 1px. -const RING_GEOMETRY = { - sm: { box: 16, radius: 6 }, - md: { box: 20, radius: 8 }, -} as const; -const RING_STROKE = 2; - -type RingMagnitude = NonNullable["magnitude"]>; -type RingTone = NonNullable["tone"]>; +import { circleVariants } from "./variants"; /** Props for {@link ProgressCircle}. */ -export type ProgressCircleProps = Omit & { - /** Completion from 0 to `max` (default max 100). */ - value: number; - /** Diameter (`sm` 16px / `md` 20px). */ - magnitude: RingMagnitude; - /** Fill color for the arc. */ - tone: RingTone; - /** Accessible name for the ring. */ - "aria-label": string; -}; +export type ProgressCircleProps = Omit & + Required, "magnitude">>; /** - * The atomic determinate progress ring — a styled `Progress.Root` wrapping an SVG: a subtle track - * circle (`layer-3-selected`) plus a toned arc proportional to the value, with rounded caps. - * `magnitude` changes the diameter (`sm` 16px / `md` 20px). `tone` drives the arc fill color via - * the `--progress-fill` CSS custom property set by `ringVariants`. Base UI `Progress.Root` owns the - * `progressbar` role + `aria-valuenow`. + * The circular ring root — a styled Base UI `Progress.Root` that sizes the ring box (`magnitude` + * `sm` 16px / `md` 20px) and owns the `progressbar` role + `aria-valuenow`. Compose a + * `ProgressCircleSvg` (holding `ProgressCircleTrack` + `ProgressCircleIndicator`) inside it. */ -export function ProgressCircle({ value, magnitude, tone, ...props }: ProgressCircleProps) { - const { box, radius } = RING_GEOMETRY[magnitude]; - const circumference = 2 * Math.PI * radius; - const max = props.max ?? 100; - // Clamp once and feed the same value to the SVG arc and the Root, so the arc - // and `aria-valuenow` never disagree for out-of-range input. - const clampedValue = Math.min(Math.max(value, 0), max); - const fraction = max > 0 ? clampedValue / max : 0; - const dashOffset = circumference * (1 - fraction); - const center = box / 2; - return ( - - - - ); +export function ProgressCircle({ magnitude, ...props }: ProgressCircleProps) { + return ; } diff --git a/packages/propel/src/ui/progress/progress-value.tsx b/packages/propel/src/ui/progress/progress-value.tsx index a67721dd..dc549870 100644 --- a/packages/propel/src/ui/progress/progress-value.tsx +++ b/packages/propel/src/ui/progress/progress-value.tsx @@ -1,16 +1,14 @@ import { Progress as BaseProgress } from "@base-ui/react/progress"; -import { type VariantProps } from "class-variance-authority"; import { progressValueVariants } from "./variants"; /** Props for {@link ProgressValue}; 1:1 with Base UI `Progress.Value`. */ -export type ProgressValueProps = Omit & - Required, "tone">>; +export type ProgressValueProps = Omit; /** - * 1:1 wrapper around Base UI `Progress.Value`. The `tone` keeps the text in the same hue as the - * fill. + * 1:1 wrapper around Base UI `Progress.Value`. Renders the percentage as a neutral readout; the + * semantic tone lives on the fill, not the number. */ -export function ProgressValue({ tone, ...props }: ProgressValueProps) { - return ; +export function ProgressValue(props: ProgressValueProps) { + return ; } diff --git a/packages/propel/src/ui/progress/progress.stories.tsx b/packages/propel/src/ui/progress/progress.stories.tsx index b3996fc0..cb619f00 100644 --- a/packages/propel/src/ui/progress/progress.stories.tsx +++ b/packages/propel/src/ui/progress/progress.stories.tsx @@ -4,6 +4,9 @@ import { expect } from "storybook/test"; import { Progress, ProgressCircle, + ProgressCircleIndicator, + ProgressCircleSvg, + ProgressCircleTrack, ProgressIndicator, ProgressLabel, ProgressTrack, @@ -12,13 +15,23 @@ import { // UI-tier story: composes the ATOMIC progress parts. `Progress` (Base UI `Progress.Root`) // owns the `progressbar` value model; `ProgressTrack` › `ProgressIndicator` paint the bar, -// `ProgressLabel`/`ProgressValue` the accessible name + trailing text. `ProgressCircle` is -// the atomic determinate ring. The ready-made linear+circular `Progress` (magnitude, the -// trailing `%`) lives in `components/progress`. +// `ProgressLabel`/`ProgressValue` the accessible name + trailing text. The circular ring is +// `ProgressCircle` › `ProgressCircleSvg` › `ProgressCircleTrack` + `ProgressCircleIndicator`. +// The ready-made linear+circular `Progress` (magnitude, the trailing `%`) lives in +// `components/progress`. const meta = { title: "UI/Progress", component: Progress, - subcomponents: { ProgressTrack, ProgressIndicator, ProgressLabel, ProgressValue, ProgressCircle }, + subcomponents: { + ProgressTrack, + ProgressIndicator, + ProgressLabel, + ProgressValue, + ProgressCircle, + ProgressCircleSvg, + ProgressCircleTrack, + ProgressCircleIndicator, + }, // The render fns assemble their own atoms; these satisfy the Root's value-model props. args: { value: 32, "aria-label": "Progress" }, decorators: [ @@ -45,9 +58,7 @@ export const Default: Story = { - - {(_, value) => (value == null ? "" : `${Math.round(value)}%`)} - + {(_, value) => (value == null ? "" : `${Math.round(value)}%`)} ), play: async ({ canvas }) => { @@ -58,15 +69,32 @@ export const Default: Story = { }; /** - * The atomic determinate ring — a styled `Progress.Root` wrapping an SVG. It owns its own - * `progressbar` role; pass `aria-label` for the accessible name, `magnitude` for the diameter (`sm` - * 16px / `md` 20px), and `tone` for the arc fill color. + * Assemble the determinate ring from atoms: `ProgressCircle` (the styled `Progress.Root`) › + * `ProgressCircleSvg` › `ProgressCircleTrack` (the subtle full ring) + `ProgressCircleIndicator` + * (the toned arc). The root owns the `progressbar` role; pass `aria-label` for the accessible name, + * `magnitude` for the diameter, and `tone` for the arc fill color. Geometry (radius, dash offset) + * is passed to the circles as SVG attributes. */ export const Circle: Story = { - render: () => ( -
- - -
- ), + render: () => { + const radius = 8; + const circumference = 2 * Math.PI * radius; + return ( + + + + + + + ); + }, }; diff --git a/packages/propel/src/ui/progress/variants.ts b/packages/propel/src/ui/progress/variants.ts index 2ec7d088..43c799cd 100644 --- a/packages/propel/src/ui/progress/variants.ts +++ b/packages/propel/src/ui/progress/variants.ts @@ -1,8 +1,8 @@ import { cva } from "class-variance-authority"; /** - * Indicator fill variants. `tone` drives the fill color of the bar (and the arc in the circular - * ring). All other indicator styles — pill radius, transition, inset — are always the same. + * Indicator fill variants. `tone` drives the fill color of the bar. All other indicator styles — + * pill radius, transition, inset — are always the same. * * Indeterminate state: Base UI sets `data-indeterminate` on its Root and propagates it to the * Indicator; we target it here with a slide-pulse animation so the bar communicates an @@ -30,19 +30,12 @@ export const progressIndicatorVariants = cva( export const progressLabelVariants = cva("text-13 font-medium text-secondary"); /** - * Value text variants. `tone` keeps the percentage in the same hue family as the fill, so the - * number reads as part of the same semantic signal. + * Value text variants. The percentage is a neutral readout (matching `ProgressLabel`'s + * `text-secondary`), not a toned signal: the semantic color lives on the fill bar/arc. Keeping the + * number neutral also avoids the low-contrast amber/green readouts against a neutral surface (toned + * text only meets WCAG AA on its own soft tone background, which the percentage does not have). */ -export const progressValueVariants = cva("text-12 font-medium tabular-nums", { - variants: { - tone: { - brand: "text-accent-primary", - success: "text-success-primary", - warning: "text-warning-primary", - danger: "text-danger-primary", - }, - }, -}); +export const progressValueVariants = cva("text-12 font-medium text-secondary tabular-nums"); export const rootVariants = cva("", { variants: { @@ -65,21 +58,44 @@ export const trackVariants = cva( ); /** - * Circular ring container variants. `tone` sets the `--progress-fill` CSS custom property which the - * indicator arc circle reads via `[stroke:var(--progress-fill)]`, keeping all className composition - * inside cva. + * Circular ring root variants. `magnitude` sets the diameter of the ring box. The arc and track + * circles fill the box; geometry (radius, dash) is passed to those parts as SVG attributes. */ -export const ringVariants = cva("shrink-0", { +export const circleVariants = cva("shrink-0", { variants: { magnitude: { sm: "size-4", md: "size-5", }, - tone: { - brand: "[--progress-fill:var(--bg-accent-primary)]", - success: "[--progress-fill:var(--bg-success-primary)]", - warning: "[--progress-fill:var(--bg-warning-primary)]", - danger: "[--progress-fill:var(--bg-danger-primary)]", - }, }, }); + +/** The circular ring's SVG viewport. Fills its `ProgressCircle` box. */ +export const circleSvgVariants = cva("block size-full"); + +/** + * The full subtle ring behind the arc. Strokes with the same `layer-3-selected` surface token the + * linear track fills with, so both variants read as the same low-emphasis track and re-tint + * together in every theme. + */ +export const circleTrackVariants = cva("[stroke:var(--bg-layer-3-selected)]"); + +/** + * The toned arc proportional to the value, with rounded caps. `tone` drives the stroke color. The + * arc is rotated so it starts at 12 o'clock; `transform-origin: center` keeps the rotation about + * the circle's center so the visual sweep stays clockwise in RTL too. The dash offset (the value + * model) is passed as an SVG attribute. + */ +export const circleIndicatorVariants = cva( + "origin-center -rotate-90 transition-[stroke-dashoffset] duration-300 ease-out", + { + variants: { + tone: { + brand: "[stroke:var(--bg-accent-primary)]", + success: "[stroke:var(--bg-success-primary)]", + warning: "[stroke:var(--bg-warning-primary)]", + danger: "[stroke:var(--bg-danger-primary)]", + }, + }, + }, +); From c66caf5b6724a2b91cb4ecb971e50605289bfce8 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 17:00:32 +0700 Subject: [PATCH 3/3] Normalize progress variant function names to rule 3a MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename each cva variant fn to exactly match the part that consumes it: - rootVariants → progressVariants (Progress root) - trackVariants → progressTrackVariants (ProgressTrack) - circleVariants → progressCircleVariants (ProgressCircle) - circleSvgVariants → progressCircleSvgVariants (ProgressCircleSvg) - circleTrackVariants → progressCircleTrackVariants (ProgressCircleTrack) - circleIndicatorVariants → progressCircleIndicatorVariants (ProgressCircleIndicator) --- .../src/ui/progress/progress-circle-indicator.tsx | 6 +++--- .../propel/src/ui/progress/progress-circle-svg.tsx | 4 ++-- .../propel/src/ui/progress/progress-circle-track.tsx | 4 ++-- packages/propel/src/ui/progress/progress-circle.tsx | 6 +++--- packages/propel/src/ui/progress/progress-track.tsx | 6 +++--- packages/propel/src/ui/progress/progress.tsx | 6 +++--- packages/propel/src/ui/progress/variants.ts | 12 ++++++------ 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/propel/src/ui/progress/progress-circle-indicator.tsx b/packages/propel/src/ui/progress/progress-circle-indicator.tsx index 33ad8c02..f95727b4 100644 --- a/packages/propel/src/ui/progress/progress-circle-indicator.tsx +++ b/packages/propel/src/ui/progress/progress-circle-indicator.tsx @@ -1,14 +1,14 @@ import { type VariantProps } from "class-variance-authority"; import type * as React from "react"; -import { circleIndicatorVariants } from "./variants"; +import { progressCircleIndicatorVariants } from "./variants"; /** Props for {@link ProgressCircleIndicator}. */ export type ProgressCircleIndicatorProps = Omit< React.ComponentPropsWithoutRef<"circle">, "className" | "style" > & - Required, "tone">>; + Required, "tone">>; /** * The toned arc proportional to the value (the circular analogue of `ProgressIndicator`). `tone` @@ -16,5 +16,5 @@ export type ProgressCircleIndicatorProps = Omit< * / `strokeDashoffset` / `strokeLinecap`) as SVG attributes. */ export function ProgressCircleIndicator({ tone, ...props }: ProgressCircleIndicatorProps) { - return ; + return ; } diff --git a/packages/propel/src/ui/progress/progress-circle-svg.tsx b/packages/propel/src/ui/progress/progress-circle-svg.tsx index b247f313..4cbd16b7 100644 --- a/packages/propel/src/ui/progress/progress-circle-svg.tsx +++ b/packages/propel/src/ui/progress/progress-circle-svg.tsx @@ -1,6 +1,6 @@ import type * as React from "react"; -import { circleSvgVariants } from "./variants"; +import { progressCircleSvgVariants } from "./variants"; /** Props for {@link ProgressCircleSvg}. */ export type ProgressCircleSvgProps = Omit< @@ -15,5 +15,5 @@ export type ProgressCircleSvgProps = Omit< * so one SVG user unit equals one pixel. */ export function ProgressCircleSvg(props: ProgressCircleSvgProps) { - return ; + return ; } diff --git a/packages/propel/src/ui/progress/progress-circle-track.tsx b/packages/propel/src/ui/progress/progress-circle-track.tsx index 412b9d07..f31d19d2 100644 --- a/packages/propel/src/ui/progress/progress-circle-track.tsx +++ b/packages/propel/src/ui/progress/progress-circle-track.tsx @@ -1,6 +1,6 @@ import type * as React from "react"; -import { circleTrackVariants } from "./variants"; +import { progressCircleTrackVariants } from "./variants"; /** Props for {@link ProgressCircleTrack}. */ export type ProgressCircleTrackProps = Omit< @@ -14,5 +14,5 @@ export type ProgressCircleTrackProps = Omit< * in. */ export function ProgressCircleTrack(props: ProgressCircleTrackProps) { - return ; + return ; } diff --git a/packages/propel/src/ui/progress/progress-circle.tsx b/packages/propel/src/ui/progress/progress-circle.tsx index 2a7439ec..814daf79 100644 --- a/packages/propel/src/ui/progress/progress-circle.tsx +++ b/packages/propel/src/ui/progress/progress-circle.tsx @@ -1,11 +1,11 @@ import { Progress as BaseProgress } from "@base-ui/react/progress"; import { type VariantProps } from "class-variance-authority"; -import { circleVariants } from "./variants"; +import { progressCircleVariants } from "./variants"; /** Props for {@link ProgressCircle}. */ export type ProgressCircleProps = Omit & - Required, "magnitude">>; + Required, "magnitude">>; /** * The circular ring root — a styled Base UI `Progress.Root` that sizes the ring box (`magnitude` @@ -13,5 +13,5 @@ export type ProgressCircleProps = Omit; + return ; } diff --git a/packages/propel/src/ui/progress/progress-track.tsx b/packages/propel/src/ui/progress/progress-track.tsx index fecc910c..2f70e39c 100644 --- a/packages/propel/src/ui/progress/progress-track.tsx +++ b/packages/propel/src/ui/progress/progress-track.tsx @@ -1,16 +1,16 @@ import { Progress as BaseProgress } from "@base-ui/react/progress"; import { type VariantProps } from "class-variance-authority"; -import { trackVariants } from "./variants"; +import { progressTrackVariants } from "./variants"; /** Props for {@link ProgressTrack}; 1:1 with Base UI `Progress.Track`. */ export type ProgressTrackProps = Omit & - VariantProps; + VariantProps; /** * 1:1 wrapper around Base UI `Progress.Track`. `magnitude` drives the track thickness (`sm` 5px / * `md` 8px). */ export function ProgressTrack({ magnitude, ...props }: ProgressTrackProps) { - return ; + return ; } diff --git a/packages/propel/src/ui/progress/progress.tsx b/packages/propel/src/ui/progress/progress.tsx index a1e6e555..958f6730 100644 --- a/packages/propel/src/ui/progress/progress.tsx +++ b/packages/propel/src/ui/progress/progress.tsx @@ -1,11 +1,11 @@ import { Progress as BaseProgress } from "@base-ui/react/progress"; import { type VariantProps } from "class-variance-authority"; -import { rootVariants } from "./variants"; +import { progressVariants } from "./variants"; /** Props for {@link Progress} (the Base UI `Progress.Root`). */ export type ProgressProps = Omit & - VariantProps; + VariantProps; /** * The atomic `Progress.Root` — maps 1:1 to Base UI's `Progress.Root`. It owns the `progressbar` @@ -19,5 +19,5 @@ export type ProgressProps = Omit * `Progress` from `@plane/propel/components/progress`. */ export function Progress({ layout, ...props }: ProgressProps) { - return ; + return ; } diff --git a/packages/propel/src/ui/progress/variants.ts b/packages/propel/src/ui/progress/variants.ts index 43c799cd..f2225566 100644 --- a/packages/propel/src/ui/progress/variants.ts +++ b/packages/propel/src/ui/progress/variants.ts @@ -37,7 +37,7 @@ export const progressLabelVariants = cva("text-13 font-medium text-secondary"); */ export const progressValueVariants = cva("text-12 font-medium text-secondary tabular-nums"); -export const rootVariants = cva("", { +export const progressVariants = cva("", { variants: { layout: { linear: "flex w-full items-center gap-2", @@ -45,7 +45,7 @@ export const rootVariants = cva("", { }, }); -export const trackVariants = cva( +export const progressTrackVariants = cva( "relative min-w-0 flex-1 overflow-hidden rounded-full bg-layer-3-selected", { variants: { @@ -61,7 +61,7 @@ export const trackVariants = cva( * Circular ring root variants. `magnitude` sets the diameter of the ring box. The arc and track * circles fill the box; geometry (radius, dash) is passed to those parts as SVG attributes. */ -export const circleVariants = cva("shrink-0", { +export const progressCircleVariants = cva("shrink-0", { variants: { magnitude: { sm: "size-4", @@ -71,14 +71,14 @@ export const circleVariants = cva("shrink-0", { }); /** The circular ring's SVG viewport. Fills its `ProgressCircle` box. */ -export const circleSvgVariants = cva("block size-full"); +export const progressCircleSvgVariants = cva("block size-full"); /** * The full subtle ring behind the arc. Strokes with the same `layer-3-selected` surface token the * linear track fills with, so both variants read as the same low-emphasis track and re-tint * together in every theme. */ -export const circleTrackVariants = cva("[stroke:var(--bg-layer-3-selected)]"); +export const progressCircleTrackVariants = cva("[stroke:var(--bg-layer-3-selected)]"); /** * The toned arc proportional to the value, with rounded caps. `tone` drives the stroke color. The @@ -86,7 +86,7 @@ export const circleTrackVariants = cva("[stroke:var(--bg-layer-3-selected)]"); * the circle's center so the visual sweep stays clockwise in RTL too. The dash offset (the value * model) is passed as an SVG attribute. */ -export const circleIndicatorVariants = cva( +export const progressCircleIndicatorVariants = cva( "origin-center -rotate-90 transition-[stroke-dashoffset] duration-300 ease-out", { variants: {