-
Notifications
You must be signed in to change notification settings - Fork 1
feat(ui): add ProgressBar component #1983
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
78c76b5
772dd91
621d0ec
c1e1300
4dc8cb5
c6ea72a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| --- | ||
| "@cloudoperators/juno-ui-components": minor | ||
| --- | ||
|
|
||
| feat(ProgressBar): add ProgressBar component | ||
|
|
||
| Adds a `ProgressBar` with a `mode` prop offering three modes: `determinate` | ||
| (clamped `value` 0-100), `busy` (animated indeterminate indicator), and | ||
| `simulated` (a self-running fake progress that advances at an uneven pace and | ||
| parks near the end, for when the final amount of incoming data is unknown). | ||
| Determinate value changes animate smoothly. | ||
|
|
||
| Accessibility: uses `role="progressbar"` with `aria-valuemin`/`aria-valuemax`. | ||
| The `determinate` mode exposes `aria-valuenow`; `busy` and `simulated` omit it | ||
| to signal an unknown value to assistive technology. Under | ||
| `prefers-reduced-motion: reduce` the determinate value transition is disabled | ||
| and the simulated animation parks statically at its end value; the `busy` | ||
| indicator keeps its gentle, non-flashing loop, since in that indeterminate | ||
| state the motion is the only signal that work is in progress. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import React, { HTMLAttributes, ReactNode } from "react" | ||
| import "./progressbar.css" | ||
|
|
||
| const progressBarBaseStyles = | ||
| "jn:border jn:border-theme-progressbar jn:rounded-xl jn:h-3 jn:p-[0.125rem] jn:overflow-hidden" | ||
|
|
||
| const simulatedSteps = [40, 58, 72, 85, 95] | ||
|
|
||
| export interface ProgressBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> { | ||
| /** | ||
| * Fill percentage of the track. Only applies in `determinate` mode. | ||
| * @default 0 | ||
| */ | ||
| value?: number | ||
| /** | ||
| * Visual mode of the progress bar. | ||
| * `determinate` fills the track to `value`. | ||
| * `busy` shows an animated indeterminate indicator. | ||
| * `simulated` runs a fake self-running progress that advances through steps separated by randomized delays and parks near the end, for when the final amount of incoming data is unknown. | ||
| * @default "determinate" | ||
| */ | ||
| mode?: "determinate" | "busy" | "simulated" | ||
| /** Accessible label for screen readers. | ||
| * @default "Progress" | ||
| */ | ||
| "aria-label"?: string | ||
| /** Tailwind width class to apply to the track. | ||
| * @default "jn:w-44" | ||
| */ | ||
| width?: string | ||
| /** Add custom class names. */ | ||
| className?: string | ||
| } | ||
|
|
||
| /** | ||
| * The `ProgressBar` component visually represents the completion status of a task or process. | ||
| * It accepts a `value` between 0 and 100 and renders a filled track scaled to that percentage. | ||
| * Values outside the valid range are clamped automatically. | ||
| * Set `mode` to `busy` for an animated indeterminate indicator, or `simulated` for a fake | ||
| * self-running progress when the final amount of incoming data is unknown. | ||
| * @see {@link ProgressBarProps} | ||
| */ | ||
| export const ProgressBar = ({ | ||
| value = 0, | ||
| mode = "determinate", | ||
| "aria-label": ariaLabel = "Progress", | ||
| width = "jn:w-44", | ||
| className = "", | ||
| ...props | ||
| }: ProgressBarProps): ReactNode => { | ||
| const clampedValue = Math.min(100, Math.max(0, value)) | ||
| const indeterminate = mode === "busy" || mode === "simulated" | ||
|
|
||
| const [simulatedWidth, setSimulatedWidth] = React.useState(0) | ||
|
|
||
| React.useEffect(() => { | ||
| if (mode !== "simulated") return | ||
| setSimulatedWidth(0) | ||
| if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The extra |
||
| setSimulatedWidth(simulatedSteps[simulatedSteps.length - 1]) | ||
| return | ||
| } | ||
| const timers: ReturnType<typeof setTimeout>[] = [] | ||
| // Nudge the bar to a small value almost immediately so the user gets instant | ||
| // feedback that work has started, before the first real step at ~2.7s. | ||
| timers.push(setTimeout(() => setSimulatedWidth(7), 200)) | ||
| let elapsed = 2700 | ||
| simulatedSteps.forEach((target) => { | ||
| timers.push(setTimeout(() => setSimulatedWidth(target), elapsed)) | ||
| elapsed += 4050 + Math.random() * 5400 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Moving the delay calculation out of the effect and into a useMemo (or a ref set on first render) means both runs use the same values: |
||
| }) | ||
| return () => timers.forEach(clearTimeout) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor one: the effect cleanup clears the pending timers, but |
||
| }, [mode]) | ||
| return ( | ||
| <div | ||
| {...props} | ||
| role="progressbar" | ||
| aria-valuenow={indeterminate ? undefined : clampedValue} | ||
| aria-valuemin={0} | ||
| aria-valuemax={100} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In |
||
| aria-label={ariaLabel} | ||
| className={`juno-progressbar ${progressBarBaseStyles} ${width} ${className}`} | ||
| > | ||
| {mode === "busy" ? ( | ||
| <div className="juno-progressbar-busy-fill jn:h-full jn:rounded-xl jn:bg-theme-progressbar" /> | ||
| ) : mode === "simulated" ? ( | ||
| <div | ||
| className="juno-progressbar-simulated-fill jn:h-full jn:rounded-xl jn:bg-theme-progressbar jn:transition-[width] jn:duration-300 jn:ease-out jn:motion-reduce:transition-none" | ||
| style={{ width: `${simulatedWidth}%` }} | ||
| /> | ||
| ) : ( | ||
| <div | ||
| className="jn:h-full jn:rounded-xl jn:bg-theme-progressbar jn:transition-[width] jn:duration-300 jn:ease-out jn:motion-reduce:transition-none" | ||
| style={{ width: `${clampedValue}%` }} | ||
| /> | ||
| )} | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import type { Meta, StoryObj } from "@storybook/react-vite" | ||
| import { ProgressBar } from "./index" | ||
|
|
||
| const meta: Meta<typeof ProgressBar> = { | ||
| title: "WIP/ProgressBar", | ||
| component: ProgressBar, | ||
| argTypes: { | ||
| value: { | ||
| control: { type: "range", min: 0, max: 100, step: 1 }, | ||
| }, | ||
| mode: { | ||
| control: { type: "select" }, | ||
| options: ["determinate", "busy", "simulated"], | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| export default meta | ||
|
|
||
| type Story = StoryObj<typeof meta> | ||
|
|
||
| export const Half: Story = { | ||
| args: { | ||
| value: 50, | ||
| }, | ||
| } | ||
|
|
||
| export const Full: Story = { | ||
| args: { | ||
| value: 100, | ||
| }, | ||
| } | ||
|
|
||
| export const Busy: Story = { | ||
| args: { | ||
| mode: "busy", | ||
| }, | ||
| } | ||
|
|
||
| export const Simulated: Story = { | ||
| args: { | ||
| mode: "simulated", | ||
| }, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import * as React from "react" | ||
| import { describe, expect, test, vi } from "vitest" | ||
| import { act, render, screen } from "@testing-library/react" | ||
| import { ProgressBar } from "./" | ||
|
|
||
| describe("ProgressBar component", () => { | ||
| test("renders with role progressbar", () => { | ||
| render(<ProgressBar />) | ||
| expect(screen.getByRole("progressbar")).toBeInTheDocument() | ||
| }) | ||
|
|
||
| test("applies juno-progressbar class", () => { | ||
| render(<ProgressBar />) | ||
| expect(screen.getByRole("progressbar")).toHaveClass("juno-progressbar") | ||
| }) | ||
|
|
||
| test("sets aria-valuenow to the provided value", () => { | ||
| render(<ProgressBar value={42} />) | ||
| expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42") | ||
| }) | ||
|
|
||
| test("sets aria-valuemin and aria-valuemax", () => { | ||
| render(<ProgressBar />) | ||
| const el = screen.getByRole("progressbar") | ||
| expect(el).toHaveAttribute("aria-valuemin", "0") | ||
| expect(el).toHaveAttribute("aria-valuemax", "100") | ||
| }) | ||
|
|
||
| test("sets default aria-label to 'Progress'", () => { | ||
| render(<ProgressBar />) | ||
| expect(screen.getByRole("progressbar")).toHaveAttribute("aria-label", "Progress") | ||
| }) | ||
|
|
||
| test("accepts a custom aria-label", () => { | ||
| render(<ProgressBar aria-label="File upload progress" />) | ||
| expect(screen.getByRole("progressbar")).toHaveAttribute("aria-label", "File upload progress") | ||
| }) | ||
|
|
||
| test("renders the fill div with width 0% when value is 0", () => { | ||
| const { container } = render(<ProgressBar value={0} />) | ||
| const el = screen.getByRole("progressbar") | ||
| expect(el.children).toHaveLength(1) | ||
| const fill = container.querySelector("[role='progressbar'] > div") as HTMLElement | ||
| expect(fill.style.width).toBe("0%") | ||
| }) | ||
|
|
||
| test("renders fill div when value is greater than 0", () => { | ||
| render(<ProgressBar value={50} />) | ||
| expect(screen.getByRole("progressbar").children).toHaveLength(1) | ||
| }) | ||
|
|
||
| test("clamps value above 100 to 100", () => { | ||
| render(<ProgressBar value={150} />) | ||
| expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100") | ||
| }) | ||
|
|
||
| test("clamps value below 0 to 0", () => { | ||
| render(<ProgressBar value={-10} />) | ||
| expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "0") | ||
| }) | ||
|
|
||
| test("applies additional className", () => { | ||
| render(<ProgressBar className="custom-class" />) | ||
| expect(screen.getByRole("progressbar")).toHaveClass("custom-class") | ||
| }) | ||
|
|
||
| test("spreads additional HTML attributes", () => { | ||
| render(<ProgressBar data-testid="pb" data-extra="yes" />) | ||
| expect(screen.getByTestId("pb")).toHaveAttribute("data-extra", "yes") | ||
| }) | ||
|
|
||
| test("renders busy indicator in busy mode", () => { | ||
| const { container } = render(<ProgressBar mode="busy" />) | ||
| expect(screen.getByRole("progressbar").children).toHaveLength(1) | ||
| expect(container.querySelector(".juno-progressbar-busy-fill")).toBeInTheDocument() | ||
| }) | ||
|
|
||
| test("does not set aria-valuenow in busy mode", () => { | ||
| render(<ProgressBar mode="busy" value={50} />) | ||
| expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow") | ||
| }) | ||
|
|
||
| test("renders the determinate fill scaled to value", () => { | ||
| const { container } = render(<ProgressBar value={50} />) | ||
| const fill = container.querySelector("[role='progressbar'] > div") | ||
| expect(fill).toBeInTheDocument() | ||
| const style = (fill as HTMLElement).getAttribute("style") | ||
| expect(style).toContain("width: 50%") | ||
| }) | ||
|
|
||
| test("renders simulated indicator in simulated mode", () => { | ||
| const { container } = render(<ProgressBar mode="simulated" />) | ||
| expect(container.querySelector(".juno-progressbar-simulated-fill")).toBeInTheDocument() | ||
| }) | ||
|
|
||
| test("does not set aria-valuenow in simulated mode", () => { | ||
| render(<ProgressBar mode="simulated" value={50} />) | ||
| expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow") | ||
| }) | ||
|
|
||
| test("nudges the simulated fill to a small value almost immediately", () => { | ||
| vi.useFakeTimers() | ||
| const { container } = render(<ProgressBar mode="simulated" />) | ||
| act(() => { | ||
| vi.advanceTimersByTime(300) | ||
| }) | ||
| const fill = container.querySelector(".juno-progressbar-simulated-fill") as HTMLElement | ||
| expect(fill.style.width).toBe("7%") | ||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| test("advances the simulated fill to the parked value over time", () => { | ||
| vi.useFakeTimers() | ||
| const { container } = render(<ProgressBar mode="simulated" />) | ||
| act(() => { | ||
| vi.advanceTimersByTime(60000) | ||
| }) | ||
| const fill = container.querySelector(".juno-progressbar-simulated-fill") as HTMLElement | ||
| expect(fill.style.width).toBe("95%") | ||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| test("parks the simulated fill at the end value immediately when reduced motion is preferred", () => { | ||
| const matchMedia = vi.fn().mockReturnValue({ matches: true }) | ||
| vi.stubGlobal("matchMedia", matchMedia) | ||
| const { container } = render(<ProgressBar mode="simulated" />) | ||
| const fill = container.querySelector(".juno-progressbar-simulated-fill") as HTMLElement | ||
| expect(fill.style.width).toBe("95%") | ||
| vi.unstubAllGlobals() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| export { ProgressBar } from "./ProgressBar.component" | ||
| export type { ProgressBarProps } from "./ProgressBar.component" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| /* ProgressBar busy indicator */ | ||
| @keyframes juno-progress-busy { | ||
| 0% { margin-left: 0%; width: 4%; } | ||
| 50% { margin-left: 8%; width: 82%; } | ||
| 100% { margin-left: 96%; width: 4%; } | ||
| } | ||
|
|
||
| /* | ||
| * Busy fill consumes the keyframe above via a class rule instead of an inline | ||
| * style, matching the Juno convention (see DateTimePicker `fpFadeInDown`). | ||
| * No `prefers-reduced-motion` guard: in `busy` mode the bar is indeterminate, | ||
| * so this gentle 1.1s loop is the only progress signal and stays running. | ||
| */ | ||
| .juno-progressbar-busy-fill { | ||
| width: 4%; | ||
| animation: juno-progress-busy 1.1s ease-in-out infinite alternate; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Worth guarding against
NaNhere. If a consumer passes something likevalue={bytesReceived / totalBytes}beforetotalBytesis known, the result isNaN.Math.minandMath.maxboth propagateNaNunchanged, soclampedValueends up asNaN, which gets serialised to the string"NaN"onaria-valuenow– an invalid value that screen readers can't interpret. The fill div also disappears because NaN > 0 is false.A simple guard before the clamp handles this cleanly: