Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/propel/src/components/progress/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export {
Progress,
type ProgressProps,
type ProgressMagnitude,
type ProgressTone,
type ProgressVariant,
} from "./progress";
145 changes: 129 additions & 16 deletions packages/propel/src/components/progress/progress.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
Expand Down Expand Up @@ -34,8 +40,20 @@ export const Magnitudes: Story = {
parameters: { controls: { disable: true } },
render: () => (
<div className="flex flex-col gap-4">
<Progress variant="linear" value={32} magnitude="sm" aria-label="Small progress" />
<Progress variant="linear" value={32} magnitude="md" aria-label="Medium progress" />
<Progress
variant="linear"
value={32}
magnitude="sm"
tone="brand"
aria-label="Small progress"
/>
<Progress
variant="linear"
value={32}
magnitude="md"
tone="brand"
aria-label="Medium progress"
/>
</div>
),
};
Expand All @@ -45,24 +63,83 @@ export const Values: Story = {
parameters: { controls: { disable: true } },
render: () => (
<div className="flex flex-col gap-4">
<Progress variant="linear" value={0} magnitude="md" aria-label="0 percent" />
<Progress variant="linear" value={50} magnitude="md" aria-label="50 percent" />
<Progress variant="linear" value={100} magnitude="md" aria-label="100 percent" />
<Progress variant="linear" value={0} magnitude="md" tone="brand" aria-label="0 percent" />
<Progress variant="linear" value={50} magnitude="md" tone="brand" aria-label="50 percent" />
<Progress variant="linear" value={100} magnitude="md" tone="brand" aria-label="100 percent" />
</div>
),
};

/** All four sentiment tones (`brand` / `success` / `warning` / `danger`). */
export const Tones: Story = {
parameters: { controls: { disable: true } },
render: () => (
<div className="flex flex-col gap-4">
<Progress
variant="linear"
value={60}
magnitude="md"
tone="brand"
aria-label="Brand progress"
/>
<Progress
variant="linear"
value={60}
magnitude="md"
tone="success"
aria-label="Success progress"
/>
<Progress
variant="linear"
value={60}
magnitude="md"
tone="warning"
aria-label="Warning progress"
/>
<Progress
variant="linear"
value={60}
magnitude="md"
tone="danger"
aria-label="Danger progress"
/>
</div>
),
};

/**
* 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.
};

Expand All @@ -71,12 +148,48 @@ export const CircularMagnitudes: Story = {
parameters: { controls: { disable: true } },
render: () => (
<div className="flex items-center gap-4">
<Progress variant="circular" value={0} magnitude="sm" aria-label="Small 0 percent" />
<Progress variant="circular" value={32} magnitude="sm" aria-label="Small 32 percent" />
<Progress variant="circular" value={100} magnitude="sm" aria-label="Small 100 percent" />
<Progress variant="circular" value={0} magnitude="md" aria-label="Medium 0 percent" />
<Progress variant="circular" value={32} magnitude="md" aria-label="Medium 32 percent" />
<Progress variant="circular" value={100} magnitude="md" aria-label="Medium 100 percent" />
<Progress
variant="circular"
value={0}
magnitude="sm"
tone="brand"
aria-label="Small 0 percent"
/>
<Progress
variant="circular"
value={32}
magnitude="sm"
tone="brand"
aria-label="Small 32 percent"
/>
<Progress
variant="circular"
value={100}
magnitude="sm"
tone="brand"
aria-label="Small 100 percent"
/>
<Progress
variant="circular"
value={0}
magnitude="md"
tone="brand"
aria-label="Medium 0 percent"
/>
<Progress
variant="circular"
value={32}
magnitude="md"
tone="brand"
aria-label="Medium 32 percent"
/>
<Progress
variant="circular"
value={100}
magnitude="md"
tone="brand"
aria-label="Medium 100 percent"
/>
</div>
),
};
Expand Down
88 changes: 74 additions & 14 deletions packages/propel/src/components/progress/progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,56 @@ import { Progress as BaseProgress } from "@base-ui/react/progress";
import {
Progress as ProgressRoot,
ProgressCircle,
ProgressCircleIndicator,
ProgressCircleSvg,
ProgressCircleTrack,
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<ProgressTrackProps["magnitude"]>;
export type ProgressTone = NonNullable<ProgressIndicatorProps["tone"]>;
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<ProgressMagnitude, { box: number; radius: number }> = {
sm: { box: 16, radius: 6 },
md: { box: 20, radius: 8 },
};
const RING_STROKE = 2;

export type ProgressProps = Omit<BaseProgress.Root.Props, "className" | "style" | "value"> & {
/** `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`.
Expand All @@ -53,21 +76,58 @@ export type ProgressProps = Omit<BaseProgress.Root.Props, "className" | "style"
* hide it with `showValue={false}`). `variant="circular"` is a small ring with no label
* (`showValue` is ignored).
*
* `tone` controls the fill color: `brand` is the accent blue, `success`/`warning`/`danger` encode
* outcome semantics (green/amber/red).
*
* Composed from the `ui/progress` primitives (`Progress` root, `ProgressTrack`,
* `ProgressIndicator`, `ProgressValue`, `ProgressCircle`), which are built on Base UI `Progress`
* (it owns the `progressbar` role + `aria-valuenow`).
* `ProgressIndicator`, `ProgressValue` for the bar; `ProgressCircle` › `ProgressCircleSvg` ›
* `ProgressCircleTrack` + `ProgressCircleIndicator` for the ring), which are built on Base UI
* `Progress` (it owns the `progressbar` role + `aria-valuenow`).
*/
export function Progress({ variant, value, magnitude, showValue = true, ...props }: ProgressProps) {
export function Progress({
variant,
value,
magnitude,
tone,
showValue = true,
...props
}: ProgressProps) {
if (variant === "circular") {
return <ProgressCircle value={value} magnitude={magnitude} {...props} />;
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 (
<ProgressCircle value={clampedValue} magnitude={magnitude} {...props}>
<ProgressCircleSvg viewBox={`0 0 ${box} ${box}`}>
<ProgressCircleTrack cx={center} cy={center} r={radius} strokeWidth={RING_STROKE} />
<ProgressCircleIndicator
tone={tone}
cx={center}
cy={center}
r={radius}
strokeWidth={RING_STROKE}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</ProgressCircleSvg>
</ProgressCircle>
);
}

return (
<ProgressRoot layout="linear" value={value} {...props}>
<ProgressTrack magnitude={magnitude}>
{/* 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. */}
<ProgressIndicator />
<ProgressIndicator tone={tone} />
</ProgressTrack>
{showValue ? (
<ProgressValue>
Expand Down
1 change: 1 addition & 0 deletions packages/propel/src/components/toast/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
14 changes: 14 additions & 0 deletions packages/propel/src/styles/animations.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/propel/src/ui/progress/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
20 changes: 20 additions & 0 deletions packages/propel/src/ui/progress/progress-circle-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type VariantProps } from "class-variance-authority";
import type * as React from "react";

import { progressCircleIndicatorVariants } from "./variants";

/** Props for {@link ProgressCircleIndicator}. */
export type ProgressCircleIndicatorProps = Omit<
React.ComponentPropsWithoutRef<"circle">,
"className" | "style"
> &
Required<Pick<VariantProps<typeof progressCircleIndicatorVariants>, "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 <circle className={progressCircleIndicatorVariants({ tone })} {...props} />;
}
Loading