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
19 changes: 19 additions & 0 deletions .changeset/progressbar-component.md
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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth guarding against NaN here. If a consumer passes something like value={bytesReceived / totalBytes} before totalBytes is known, the result is NaN. Math.min and Math.max both propagate NaN unchanged, so clampedValue ends up as NaN, which gets serialised to the string "NaN" on aria-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:

const clampedValue = Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0))

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window.matchMedia?.('...') will return undefined in test environments (JSDOM doesn't implement matchMedia), and then .matches on undefined throws a TypeError. The fix is to extend the optional chain one step further:

window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches

The extra ?. before .matches means the whole expression safely evaluates to undefined rather than throwing when matchMedia isn't available.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Math.random() is called inside the effect, which causes a subtle difference between development and production. React 18 runs effects twice in StrictMode (dev only) to help catch side effects — the first run's timers are immediately cleaned up and discarded, and the second run gets a fresh set of random numbers. So the animation timing you see while developing is different from what runs in production, which makes it hard to verify the pacing visually.

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:

const delays = React.useMemo(() => simulatedSteps.map((_, i) => 2700 + i * (4050 + Math.random() * 5400)), [])

})
return () => timers.forEach(clearTimeout)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor one: the effect cleanup clears the pending timers, but simulatedWidth state isn't reset until the effect re-runs and calls setSimulatedWidth(0) on line 63. This means there's a brief window – one render frame - where the bar could flash its previous width if mode switches away from and back to simulated. Adding the reset to the cleanup too would make the transition cleaner:

return () => {
  timers.forEach(clearTimeout)
  setSimulatedWidth(0)
}

}, [mode])
return (
<div
{...props}
role="progressbar"
aria-valuenow={indeterminate ? undefined : clampedValue}
aria-valuemin={0}
aria-valuemax={100}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In busy and simulated modes, aria-valuenow is correctly omitted (line 83), but aria-valuemin and aria-valuemax are still always rendered. Some assistive technologies, when they see a numeric range without a current value, will announce something like "0 of 100" rather than treating the bar as indeterminate, which is misleading to the user. The fix is to gate all three range attributes together:

aria-valuenow={indeterminate ? undefined : clampedValue}
aria-valuemin={indeterminate ? undefined : 0}
aria-valuemax={indeterminate ? undefined : 100}

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()
})
})
7 changes: 7 additions & 0 deletions packages/ui-components/src/components/ProgressBar/index.ts
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;
}
11 changes: 11 additions & 0 deletions packages/ui-components/src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
@import "./components/DataGridRow/data-grid-row.css" layer(utilities);
@import "./components/SideNavigation/sidenavigation.css" layer(utilities);
@import "./components/PageFooter/page-footer.css" layer(utilities);
@import "./components/ProgressBar/progressbar.css" layer(utilities);

:root,
:host {
Expand Down Expand Up @@ -462,6 +463,10 @@
--border-color-theme-switch-default: var(--color-switch-default-border);
--border-color-theme-switch-hover: var(--color-switch-hover-border);

/* ProgressBar */
--background-color-theme-progressbar: var(--color-progressbar-fill);
--border-color-theme-progressbar: var(--color-progressbar-border);

--border-color-theme-tab-navigation-content-bottom: var(--color-tabnavigation-content-bottom-border);

--border-color-theme-tab-active-bottom: var(--color-text-default);
Expand Down Expand Up @@ -705,6 +710,9 @@
--color-required-bg: var(--color-accent);
/* LT Spinner */
--color-spinner-primary: var(--color-accent);
/* LT ProgressBar */
--color-progressbar-fill: var(--color-accent);
--color-progressbar-border: var(--color-text-light);
/* LT Syntax Highlighting */
--color-syntax-highlight-base00: var(--color-codeblock-bg); /* bg */
--color-syntax-highlight-base01: var(--color-juno-grey-light-3); /* ? */
Expand Down Expand Up @@ -970,6 +978,9 @@
--color-required-bg: var(--color-accent);
/* DT Spinner */
--color-spinner-primary: var(--color-accent);
/* DT ProgressBar */
--color-progressbar-fill: var(--color-accent);
--color-progressbar-border: var(--color-text-light);
/* DT Syntax Highlighting */
--color-syntax-highlight-base00: var(--color-codeblock-bg); /* bg */
--color-syntax-highlight-base01: var(--color-juno-grey-blue-3); /* ? */
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export { PageFooter } from "./components/PageFooter/PageFooter.component"
export { PageHeader } from "./components/PageHeader/PageHeader.component"
export { Pagination } from "./components/Pagination/Pagination.component"
export { Pill } from "./components/Pill/Pill.component"
export { ProgressBar } from "./components/ProgressBar/ProgressBar.component"
export {
PopupMenu,
PopupMenuToggle,
Expand Down Expand Up @@ -190,6 +191,7 @@ export type { PageFooterProps } from "./components/PageFooter/PageFooter.compone
export type { PageHeaderProps } from "./components/PageHeader/PageHeader.component"
export type { PaginationProps } from "./components/Pagination/Pagination.component"
export type { PillProps } from "./components/Pill/Pill.component"
export type { ProgressBarProps } from "./components/ProgressBar/ProgressBar.component"
export type {
PopupMenuProps,
PopupMenuContextType,
Expand Down
Loading
Loading