Skip to content
Merged
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
26 changes: 26 additions & 0 deletions packages/app-elements/src/ui/atoms/RadialProgress.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ describe("RadialProgress", () => {
expect(queryByTestId("radial-progress-percentage")).not.toBeInTheDocument()
})

test("Should be rendered as indeterminate", () => {
const { getByTestId, queryByTestId } = render(
<RadialProgress percentage="indeterminate" />,
)
expect(queryByTestId("radial-progress-indeterminate")).toBeInTheDocument()
expect(queryByTestId("radial-progress-pending")).not.toBeInTheDocument()
expect(queryByTestId("radial-progress-percentage")).not.toBeInTheDocument()
expect(getByTestId("radial-progress")).toHaveClass("animate-spin")
})

test("Should not statically rotate the indeterminate arc", () => {
const { getByTestId } = render(
<RadialProgress percentage="indeterminate" />,
)
expect(getByTestId("radial-progress")).not.toHaveClass("-rotate-90")
})

test.each([
[undefined, "Pending"],
["indeterminate", "In progress"],
[42, "42%"],
] as const)("Should title %s as %s", (percentage, expectedTitle) => {
const { getByTitle } = render(<RadialProgress percentage={percentage} />)
expect(getByTitle(expectedTitle)).toBeInTheDocument()
})

test("Should render an icon", () => {
const { queryByTestId } = render(
<RadialProgress percentage={undefined} icon="shoppingBag" />,
Expand Down
56 changes: 49 additions & 7 deletions packages/app-elements/src/ui/atoms/RadialProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import { StatusIcon, type StatusIconProps } from "./StatusIcon"

export interface RadialProgressProps extends SVGAttributes<SVGElement> {
/**
* Progress percentage express as number from 0 to 100.
* When not specified, it will render a dashed circle (eg: pending state)
* How much is known about the progress, on a single axis:
* - not specified: the work has not started, rendered as a dashed circle
* - `"indeterminate"`: it has started, with no knowable end, rendered as a
* spinning arc
* - a number from 0 to 100: it is measurable, rendered as a filled circle
*/
percentage?: number
percentage?: number | "indeterminate"
/**
* Size variant to match `Icon` dimension.
* (small: 24px, medium: 36px, large: 42px, x-large: 56px)
Expand All @@ -26,9 +29,10 @@ export interface RadialProgressProps extends SVGAttributes<SVGElement> {
}

/**
* Used to render a radial progress with a pending and a progress state. It also accepts an optional icon to be rendered in the center of the circle.
* Used to render a radial progress with a pending, an indeterminate and a progress state. It also accepts an optional icon to be rendered in the center of the circle.
* <span type="info">
* When passing a `percentage` as number, it will show a progress circle, filled with the given percentage.
* When passing `percentage="indeterminate"`, it will spin, for work that has started but has no knowable end (eg: a request handed to a background worker).
* If `percentage` is not passed, it will be rendered as a dashed circle to represent the pending state.
* </span>
*/
Expand All @@ -53,14 +57,21 @@ function RadialProgress({
const emptyOffset =
circumference - (parsePercentageRange(percentage) / 100) * circumference

const isIndeterminate = percentage === "indeterminate"
// Inset by half the stroke, unlike the other two states: the arc has round
// caps, and at r = sizePixels the viewBox would clip them flat.
const arcRadius = sizePixels - INDETERMINATE_STROKE_WIDTH / 2
const arcCircumference = arcRadius * 2 * Math.PI

return (
<div className="relative">
<svg
data-testid="radial-progress"
viewBox={viewBox}
xmlns="http://www.w3.org/2000/svg"
className={cn(
"transform -rotate-90 rounded-full",
"rounded-full",
isIndeterminate ? "animate-spin" : "transform -rotate-90",
{
"mx-auto": align === "center",
},
Expand All @@ -70,8 +81,24 @@ function RadialProgress({
height={sizePixels}
{...rest}
>
<title>{percentage == null ? "Pending" : `${percentage}%`}</title>
{percentage == null ? (
<title>{getTitle(percentage)}</title>
{isIndeterminate ? (
// indeterminate
<circle
data-testid="radial-progress-indeterminate"
cx={sizePixels}
cy={sizePixels}
r={arcRadius}
className="text-gray-200"
stroke="currentColor"
strokeWidth={INDETERMINATE_STROKE_WIDTH}
strokeLinecap="round"
// Three quarters drawn, one quarter left open, which is what makes
// the rotation readable.
strokeDasharray={`${arcCircumference * 0.75} ${arcCircumference}`}
fill={icon != null ? "white" : "transparent"}
/>
) : percentage == null ? (
// pending
<circle
data-testid="radial-progress-pending"
Expand Down Expand Up @@ -123,6 +150,21 @@ function RadialProgress({
)
}

function getTitle(percentage: RadialProgressProps["percentage"]): string {
if (percentage === "indeterminate") {
return "In progress"
}

return percentage == null ? "Pending" : `${percentage}%`
}

/**
* Stroke of the indeterminate arc, in viewBox units, so half this value is the
* rendered thickness. Thinner than the measured circle's `12`, which would
* read as a progress ring rather than a spinner.
*/
const INDETERMINATE_STROKE_WIDTH = 6

/**
* Enforce a range between 0 and 100
* @param percentage probably a number
Expand Down
10 changes: 10 additions & 0 deletions packages/docs/src/stories/atoms/RadialProgress.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ Percentage.parameters = {
},
}

/**
* Indeterminate state, for work that has started but has no knowable end: the request
* has been accepted and a background worker is performing it, so there is no percentage
* to report. It renders as a spinning arc.
*/
export const Indeterminate = Template.bind({})
Indeterminate.args = {
percentage: "indeterminate",
}

/**
* You can optionally pass an icon to be rendered in the center of the circle.
* <span type="info">
Expand Down
Loading