From 78c76b574309cd76d8abc0631feb156a74049e7a Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Fri, 18 Sep 2026 11:02:10 +0200 Subject: [PATCH 01/11] feat(ui): add ProgressBar component Adds a ProgressBar with determinate (clamped value 0-100) and indeterminate busy modes. The busy indicator keyframe lives in a component-local progressbar.css (imported via global.css layer and the component), matching the Juno CSS convention for animations. Accessibility: role="progressbar" with aria-valuemin/max; busy mode omits aria-valuenow to signal an unknown value, and the value transition is disabled under prefers-reduced-motion (WCAG 2.3.3). Signed-off-by: MartinS-git --- .changeset/progressbar-component.md | 15 +++ .../ProgressBar/ProgressBar.component.tsx | 76 ++++++++++++++++ .../ProgressBar/ProgressBar.stories.tsx | 58 ++++++++++++ .../ProgressBar/ProgressBar.test.tsx | 91 +++++++++++++++++++ .../src/components/ProgressBar/index.ts | 7 ++ .../components/ProgressBar/progressbar.css | 13 +++ packages/ui-components/src/global.css | 11 +++ packages/ui-components/src/index.ts | 2 + packages/ui-components/src/theme.css | 11 ++- 9 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 .changeset/progressbar-component.md create mode 100644 packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx create mode 100644 packages/ui-components/src/components/ProgressBar/ProgressBar.stories.tsx create mode 100644 packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx create mode 100644 packages/ui-components/src/components/ProgressBar/index.ts create mode 100644 packages/ui-components/src/components/ProgressBar/progressbar.css diff --git a/.changeset/progressbar-component.md b/.changeset/progressbar-component.md new file mode 100644 index 0000000000..7c1615595a --- /dev/null +++ b/.changeset/progressbar-component.md @@ -0,0 +1,15 @@ +--- +"@cloudoperators/juno-ui-components": minor +--- + +feat(ProgressBar): add ProgressBar component + +Adds a `ProgressBar` with a determinate mode (clamped `value` 0-100) and an +indeterminate `busy` mode with an animated indicator. The determinate fill +uses an eased `width` transition so value jumps animate smoothly, with +universal browser support. + +Accessibility: uses `role="progressbar"` with `aria-valuemin`/`aria-valuemax`; +the determinate mode exposes `aria-valuenow`, while the `busy` mode omits it to +signal an unknown value to assistive technology. The value transition is +disabled under `prefers-reduced-motion: reduce` (WCAG 2.3.3). diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx new file mode 100644 index 0000000000..70a3a1211d --- /dev/null +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx @@ -0,0 +1,76 @@ +/* + * 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" + +export interface ProgressBarProps extends Omit, "children"> { + /** + * Fill percentage of the track. + * @default 0 + */ + value?: number + /** + * When `true`, shows an animated indeterminate indicator. Disables the `value` prop. + * @default false + */ + busy?: boolean + /** 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 `busy` to `true` to show an animated indeterminate state when progress is unknown. + * @see {@link ProgressBarProps} + */ +export const ProgressBar = ({ + value = 0, + busy = false, + "aria-label": ariaLabel = "Progress", + width = "jn:w-44", + className = "", + ...props +}: ProgressBarProps): ReactNode => { + const clampedValue = Math.min(100, Math.max(0, value)) + return ( +
+ {busy ? ( +
+ ) : ( + clampedValue > 0 && ( +
+ ) + )} +
+ ) +} diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.stories.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.stories.tsx new file mode 100644 index 0000000000..bb9e40c296 --- /dev/null +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.stories.tsx @@ -0,0 +1,58 @@ +/* + * 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 = { + title: "WIP/ProgressBar", + component: ProgressBar, + argTypes: { + value: { + control: { type: "range", min: 0, max: 100, step: 1 }, + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + value: 0, + }, +} + +export const Quarter: Story = { + args: { + value: 25, + }, +} + +export const Half: Story = { + args: { + value: 50, + }, +} + +export const Full: Story = { + args: { + value: 100, + }, +} + +export const Busy: Story = { + args: { + busy: true, + }, +} + +export const Playground: Story = { + args: { + value: 50, + busy: false, + }, +} diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx new file mode 100644 index 0000000000..850476af4a --- /dev/null +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx @@ -0,0 +1,91 @@ +/* + * 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 } from "vitest" +import { render, screen } from "@testing-library/react" +import { ProgressBar } from "./" + +describe("ProgressBar component", () => { + test("renders with role progressbar", () => { + render() + expect(screen.getByRole("progressbar")).toBeInTheDocument() + }) + + test("applies juno-progressbar class", () => { + render() + expect(screen.getByRole("progressbar")).toHaveClass("juno-progressbar") + }) + + test("sets aria-valuenow to the provided value", () => { + render() + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42") + }) + + test("sets aria-valuemin and aria-valuemax", () => { + render() + 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() + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-label", "Progress") + }) + + test("accepts a custom aria-label", () => { + render() + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-label", "File upload progress") + }) + + test("does not render fill div when value is 0", () => { + render() + expect(screen.getByRole("progressbar").children).toHaveLength(0) + }) + + test("renders fill div when value is greater than 0", () => { + render() + expect(screen.getByRole("progressbar").children).toHaveLength(1) + }) + + test("clamps value above 100 to 100", () => { + render() + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100") + }) + + test("clamps value below 0 to 0", () => { + render() + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "0") + }) + + test("applies additional className", () => { + render() + expect(screen.getByRole("progressbar")).toHaveClass("custom-class") + }) + + test("spreads additional HTML attributes", () => { + render() + expect(screen.getByTestId("pb")).toHaveAttribute("data-extra", "yes") + }) + + test("renders busy indicator when busy is true", () => { + render() + expect(screen.getByRole("progressbar").children).toHaveLength(1) + }) + + test("does not set aria-valuenow when busy", () => { + render() + expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow") + }) + + test("ignores value fill when busy is true", () => { + const { container } = render() + const fill = container.querySelector("[role='progressbar'] > div") + expect(fill).toBeInTheDocument() + const style = (fill as HTMLElement).getAttribute("style") + expect(style).toContain("width: 50%") + }) +}) diff --git a/packages/ui-components/src/components/ProgressBar/index.ts b/packages/ui-components/src/components/ProgressBar/index.ts new file mode 100644 index 0000000000..1fbfff504e --- /dev/null +++ b/packages/ui-components/src/components/ProgressBar/index.ts @@ -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" diff --git a/packages/ui-components/src/components/ProgressBar/progressbar.css b/packages/ui-components/src/components/ProgressBar/progressbar.css new file mode 100644 index 0000000000..c6d6ce6159 --- /dev/null +++ b/packages/ui-components/src/components/ProgressBar/progressbar.css @@ -0,0 +1,13 @@ +/* + * 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%; } + 4.5% { margin-left: 0%; width: 4%; } + 50% { margin-left: 8%; width: 82%; } + 95.5% { margin-left: 96%; width: 4%; } + 100% { margin-left: 96%; width: 4%; } +} diff --git a/packages/ui-components/src/global.css b/packages/ui-components/src/global.css index e425fe5035..af507d450b 100644 --- a/packages/ui-components/src/global.css +++ b/packages/ui-components/src/global.css @@ -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 { @@ -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); @@ -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); /* ? */ @@ -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); /* ? */ diff --git a/packages/ui-components/src/index.ts b/packages/ui-components/src/index.ts index 73e3431126..c33f1bdc47 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -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, @@ -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, diff --git a/packages/ui-components/src/theme.css b/packages/ui-components/src/theme.css index d5308e0dd3..24bdddbba5 100644 --- a/packages/ui-components/src/theme.css +++ b/packages/ui-components/src/theme.css @@ -450,6 +450,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); @@ -677,6 +681,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); /* ? */ @@ -957,7 +964,9 @@ --color-required-bg: var(--color-accent); /* DT Spinner */ --color-spinner-primary: var(--color-accent); - /* DT Syntax Highlighting */ + /* DT ProgressBar */ + --color-progressbar-fill: var(--color-accent); + --color-progressbar-border: var(--color-text-light); --color-syntax-highlight-base00: var(--color-codeblock-bg); /* bg */ --color-syntax-highlight-base01: var(--color-juno-grey-blue-3); /* ? */ --color-syntax-highlight-base02: #bbb; /* lines and boxes */ From 772dd91c6c2488e7fd3f96f9fa9ba809ed601fce Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Fri, 18 Sep 2026 14:16:15 +0200 Subject: [PATCH 02/11] feat(ui): add ProgressBar simulated mode and replace busy boolean with mode enum Signed-off-by: MartinS-git --- .changeset/progressbar-component.md | 18 +++++++++------ .../ProgressBar/ProgressBar.component.tsx | 23 ++++++++++++------- .../ProgressBar/ProgressBar.stories.tsx | 14 +++++++++-- .../ProgressBar/ProgressBar.test.tsx | 22 +++++++++++++----- .../components/ProgressBar/progressbar.css | 21 +++++++++++++++++ 5 files changed, 75 insertions(+), 23 deletions(-) diff --git a/.changeset/progressbar-component.md b/.changeset/progressbar-component.md index 7c1615595a..4fcd9384bf 100644 --- a/.changeset/progressbar-component.md +++ b/.changeset/progressbar-component.md @@ -4,12 +4,16 @@ feat(ProgressBar): add ProgressBar component -Adds a `ProgressBar` with a determinate mode (clamped `value` 0-100) and an -indeterminate `busy` mode with an animated indicator. The determinate fill -uses an eased `width` transition so value jumps animate smoothly, with -universal browser support. +Adds a `ProgressBar` with a `mode` prop offering three modes: `determinate` +(clamped `value` 0-100), `busy` (animated indeterminate indicator), and +`simulated` (a fake self-running progress that decelerates through irregular +steps and parks near the end, for when the final amount of incoming data is +unknown). The determinate fill uses an eased `width` transition so value jumps +animate smoothly, with universal browser support. Accessibility: uses `role="progressbar"` with `aria-valuemin`/`aria-valuemax`; -the determinate mode exposes `aria-valuenow`, while the `busy` mode omits it to -signal an unknown value to assistive technology. The value transition is -disabled under `prefers-reduced-motion: reduce` (WCAG 2.3.3). +the determinate mode exposes `aria-valuenow`, while the `busy` and `simulated` +modes omit it to signal an unknown value to assistive technology. Motion is +reduced under `prefers-reduced-motion: reduce` (WCAG 2.3.3): the determinate +value transition is disabled and the simulated animation is turned off, holding +its parked position statically. diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx index 70a3a1211d..324416dfab 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx @@ -11,15 +11,18 @@ const progressBarBaseStyles = export interface ProgressBarProps extends Omit, "children"> { /** - * Fill percentage of the track. + * Fill percentage of the track. Only applies in `determinate` mode. * @default 0 */ value?: number /** - * When `true`, shows an animated indeterminate indicator. Disables the `value` prop. - * @default false + * 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 decelerates through irregular steps and parks near the end, for when the final amount of incoming data is unknown. + * @default "determinate" */ - busy?: boolean + mode?: "determinate" | "busy" | "simulated" /** Accessible label for screen readers. * @default "Progress" */ @@ -36,33 +39,37 @@ export interface ProgressBarProps extends Omit, " * 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 `busy` to `true` to show an animated indeterminate state when progress is unknown. + * 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, - busy = false, + 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" return (
- {busy ? ( + {mode === "busy" ? (
+ ) : mode === "simulated" ? ( +
) : ( clampedValue > 0 && (
= { value: { control: { type: "range", min: 0, max: 100, step: 1 }, }, + mode: { + control: { type: "select" }, + options: ["determinate", "busy", "simulated"], + }, }, } @@ -46,13 +50,19 @@ export const Full: Story = { export const Busy: Story = { args: { - busy: true, + mode: "busy", + }, +} + +export const Simulated: Story = { + args: { + mode: "simulated", }, } export const Playground: Story = { args: { value: 50, - busy: false, + mode: "determinate", }, } diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx index 850476af4a..8c9d02667a 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx @@ -71,21 +71,31 @@ describe("ProgressBar component", () => { expect(screen.getByTestId("pb")).toHaveAttribute("data-extra", "yes") }) - test("renders busy indicator when busy is true", () => { - render() + test("renders busy indicator in busy mode", () => { + render() expect(screen.getByRole("progressbar").children).toHaveLength(1) }) - test("does not set aria-valuenow when busy", () => { - render() + test("does not set aria-valuenow in busy mode", () => { + render() expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow") }) - test("ignores value fill when busy is true", () => { - const { container } = render() + test("renders the determinate fill scaled to value", () => { + const { container } = render() 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() + expect(container.querySelector(".juno-progressbar-simulated-fill")).toBeInTheDocument() + }) + + test("does not set aria-valuenow in simulated mode", () => { + render() + expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow") + }) }) diff --git a/packages/ui-components/src/components/ProgressBar/progressbar.css b/packages/ui-components/src/components/ProgressBar/progressbar.css index c6d6ce6159..850e6893bc 100644 --- a/packages/ui-components/src/components/ProgressBar/progressbar.css +++ b/packages/ui-components/src/components/ProgressBar/progressbar.css @@ -11,3 +11,24 @@ 95.5% { margin-left: 96%; width: 4%; } 100% { margin-left: 96%; width: 4%; } } + +/* ProgressBar simulated (fake) progress: fast start, decelerating, parks near the end */ +@keyframes juno-progress-simulated { + 0% { width: 0%; } + 8% { width: 40%; } + 25% { width: 58%; } + 50% { width: 72%; } + 78% { width: 83%; } + 100% { width: 90%; } +} + +.juno-progressbar-simulated-fill { + width: 90%; + animation: juno-progress-simulated 4s ease-out forwards; +} + +@media (prefers-reduced-motion: reduce) { + .juno-progressbar-simulated-fill { + animation: none; + } +} From 621d0ec175a17a4c018433d9db51b6caffa7750c Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Fri, 18 Sep 2026 14:54:18 +0200 Subject: [PATCH 03/11] fix(ui): smooth ProgressBar busy loop and randomize simulated delays Remove the flat hold keyframes at the start and end of the busy animation so the indicator moves continuously through both turning points instead of pausing just before 0 and 100. Drive the simulated mode from JS with randomized per-step delays that park near the end, and trim the stories down to Half, Full, Busy and Simulated. Signed-off-by: MartinS-git --- .changeset/progressbar-component.md | 5 ++-- .../ProgressBar/ProgressBar.component.tsx | 26 +++++++++++++++++-- .../ProgressBar/ProgressBar.stories.tsx | 19 -------------- .../ProgressBar/ProgressBar.test.tsx | 15 +++++++++-- .../components/ProgressBar/progressbar.css | 23 ---------------- 5 files changed, 40 insertions(+), 48 deletions(-) diff --git a/.changeset/progressbar-component.md b/.changeset/progressbar-component.md index 4fcd9384bf..74b315b9e0 100644 --- a/.changeset/progressbar-component.md +++ b/.changeset/progressbar-component.md @@ -6,8 +6,9 @@ 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 fake self-running progress that decelerates through irregular -steps and parks near the end, for when the final amount of incoming data is +`simulated` (a fake self-running progress that advances through steps separated +by randomized delays and parks near the end, so each run looks like data +trickling in at an uneven pace, for when the final amount of incoming data is unknown). The determinate fill uses an eased `width` transition so value jumps animate smoothly, with universal browser support. diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx index 324416dfab..5494e42cc6 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx @@ -9,6 +9,8 @@ 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, "children"> { /** * Fill percentage of the track. Only applies in `determinate` mode. @@ -19,7 +21,7 @@ export interface ProgressBarProps extends Omit, " * 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 decelerates through irregular steps and parks near the end, for when the final amount of incoming data is unknown. + * `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" @@ -53,6 +55,23 @@ export const ProgressBar = ({ }: 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 + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) { + setSimulatedWidth(simulatedSteps[simulatedSteps.length - 1]) + return + } + const timers: ReturnType[] = [] + let elapsed = 300 + simulatedSteps.forEach((target) => { + timers.push(setTimeout(() => setSimulatedWidth(target), elapsed)) + elapsed += 450 + Math.random() * 600 + }) + return () => timers.forEach(clearTimeout) + }, [mode]) return (
) : mode === "simulated" ? ( -
+
) : ( clampedValue > 0 && (
-export const Default: Story = { - args: { - value: 0, - }, -} - -export const Quarter: Story = { - args: { - value: 25, - }, -} - export const Half: Story = { args: { value: 50, @@ -59,10 +47,3 @@ export const Simulated: Story = { mode: "simulated", }, } - -export const Playground: Story = { - args: { - value: 50, - mode: "determinate", - }, -} diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx index 8c9d02667a..f2c87935bc 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx @@ -4,8 +4,8 @@ */ import * as React from "react" -import { describe, expect, test } from "vitest" -import { render, screen } from "@testing-library/react" +import { describe, expect, test, vi } from "vitest" +import { act, render, screen } from "@testing-library/react" import { ProgressBar } from "./" describe("ProgressBar component", () => { @@ -98,4 +98,15 @@ describe("ProgressBar component", () => { render() expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow") }) + + test("advances the simulated fill to the parked value over time", () => { + vi.useFakeTimers() + const { container } = render() + act(() => { + vi.advanceTimersByTime(10000) + }) + const fill = container.querySelector(".juno-progressbar-simulated-fill") as HTMLElement + expect(fill.style.width).toBe("95%") + vi.useRealTimers() + }) }) diff --git a/packages/ui-components/src/components/ProgressBar/progressbar.css b/packages/ui-components/src/components/ProgressBar/progressbar.css index 850e6893bc..a274b5c5e3 100644 --- a/packages/ui-components/src/components/ProgressBar/progressbar.css +++ b/packages/ui-components/src/components/ProgressBar/progressbar.css @@ -6,29 +6,6 @@ /* ProgressBar busy indicator */ @keyframes juno-progress-busy { 0% { margin-left: 0%; width: 4%; } - 4.5% { margin-left: 0%; width: 4%; } 50% { margin-left: 8%; width: 82%; } - 95.5% { margin-left: 96%; width: 4%; } 100% { margin-left: 96%; width: 4%; } } - -/* ProgressBar simulated (fake) progress: fast start, decelerating, parks near the end */ -@keyframes juno-progress-simulated { - 0% { width: 0%; } - 8% { width: 40%; } - 25% { width: 58%; } - 50% { width: 72%; } - 78% { width: 83%; } - 100% { width: 90%; } -} - -.juno-progressbar-simulated-fill { - width: 90%; - animation: juno-progress-simulated 4s ease-out forwards; -} - -@media (prefers-reduced-motion: reduce) { - .juno-progressbar-simulated-fill { - animation: none; - } -} From c1e130090d8507c030b549eb3393ac0407a761d2 Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Mon, 21 Sep 2026 13:09:44 +0200 Subject: [PATCH 04/11] fix(ui): move ProgressBar busy animation to CSS and address review findings Move the busy indicator's keyframe animation from an inline style into a .juno-progressbar-busy-fill class in the component-local progressbar.css, consuming the existing juno-progress-busy keyframe by name (matching the Juno convention, e.g. DateTimePicker's fpFadeInDown). Also bundle the approved review fixes: - always render the determinate fill (width 0% at value 0) instead of gating it behind value > 0 - drop the minWidth from the determinate fill - spread {...props} before role/aria-* so consumer props cannot override the ARIA semantics - reset simulatedWidth on mode change to avoid a stale value On prefers-reduced-motion the busy animation is intentionally kept running: in busy mode the bar is indeterminate (no aria-valuenow), so the gentle 1.1s loop is the only signal that work is in progress. The determinate transition and the simulated animation still honor the preference. Signed-off-by: MartinS-git --- .changeset/progressbar-component.md | 25 ++++++++++---- .../ProgressBar/ProgressBar.component.tsx | 25 +++++++------- .../ProgressBar/ProgressBar.test.tsx | 34 ++++++++++++++++--- .../components/ProgressBar/progressbar.css | 11 ++++++ 4 files changed, 71 insertions(+), 24 deletions(-) diff --git a/.changeset/progressbar-component.md b/.changeset/progressbar-component.md index 74b315b9e0..d74dc78223 100644 --- a/.changeset/progressbar-component.md +++ b/.changeset/progressbar-component.md @@ -6,15 +6,28 @@ 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 fake self-running progress that advances through steps separated -by randomized delays and parks near the end, so each run looks like data +`simulated` (a fake self-running progress that starts with a quick initial +nudge for immediate feedback, then advances through steps separated by +randomized delays and parks near the end, so each run looks like data trickling in at an uneven pace, for when the final amount of incoming data is unknown). The determinate fill uses an eased `width` transition so value jumps animate smoothly, with universal browser support. Accessibility: uses `role="progressbar"` with `aria-valuemin`/`aria-valuemax`; the determinate mode exposes `aria-valuenow`, while the `busy` and `simulated` -modes omit it to signal an unknown value to assistive technology. Motion is -reduced under `prefers-reduced-motion: reduce` (WCAG 2.3.3): the determinate -value transition is disabled and the simulated animation is turned off, holding -its parked position statically. +modes omit it to signal an unknown value to assistive technology. Under +`prefers-reduced-motion: reduce` (WCAG 2.3.3) the decorative and long-running +motion is reduced: the determinate value transition is disabled and the +simulated animation parks statically at its end value. The `busy` animation is +intentionally kept running, because in that indeterminate state the gentle 1.1s +loop is the only signal that work is in progress; freezing it would leave a +static sliver that reads as "stuck". The motion is deliberately low-frequency +and non-flashing, staying within the reduced-motion guidance for essential, +non-decorative status feedback. + +Implementation: the `busy` keyframe animation lives in a +`.juno-progressbar-busy-fill` class in the component-local `progressbar.css` +(consuming the `juno-progress-busy` keyframe by name) rather than as an inline +style, matching the Juno convention. The determinate fill is always rendered +(at `width: 0%` when `value` is 0), and the simulated fill resets on mode +change. diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx index 5494e42cc6..fdd603849c 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx @@ -60,45 +60,44 @@ export const ProgressBar = ({ React.useEffect(() => { if (mode !== "simulated") return + setSimulatedWidth(0) if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) { setSimulatedWidth(simulatedSteps[simulatedSteps.length - 1]) return } const timers: ReturnType[] = [] - let elapsed = 300 + // 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 += 450 + Math.random() * 600 + elapsed += 4050 + Math.random() * 5400 }) return () => timers.forEach(clearTimeout) }, [mode]) return (
{mode === "busy" ? ( -
+
) : mode === "simulated" ? (
) : ( - clampedValue > 0 && ( -
- ) +
)}
) diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx index f2c87935bc..9ad5a97eeb 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx @@ -41,9 +41,12 @@ describe("ProgressBar component", () => { expect(screen.getByRole("progressbar")).toHaveAttribute("aria-label", "File upload progress") }) - test("does not render fill div when value is 0", () => { - render() - expect(screen.getByRole("progressbar").children).toHaveLength(0) + test("renders the fill div with width 0% when value is 0", () => { + const { container } = render() + 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", () => { @@ -72,8 +75,9 @@ describe("ProgressBar component", () => { }) test("renders busy indicator in busy mode", () => { - render() + const { container } = render() expect(screen.getByRole("progressbar").children).toHaveLength(1) + expect(container.querySelector(".juno-progressbar-busy-fill")).toBeInTheDocument() }) test("does not set aria-valuenow in busy mode", () => { @@ -99,14 +103,34 @@ describe("ProgressBar component", () => { expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow") }) + test("nudges the simulated fill to a small value almost immediately", () => { + vi.useFakeTimers() + const { container } = render() + 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() act(() => { - vi.advanceTimersByTime(10000) + 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() + const fill = container.querySelector(".juno-progressbar-simulated-fill") as HTMLElement + expect(fill.style.width).toBe("95%") + vi.unstubAllGlobals() + }) }) diff --git a/packages/ui-components/src/components/ProgressBar/progressbar.css b/packages/ui-components/src/components/ProgressBar/progressbar.css index a274b5c5e3..d09c77dc19 100644 --- a/packages/ui-components/src/components/ProgressBar/progressbar.css +++ b/packages/ui-components/src/components/ProgressBar/progressbar.css @@ -9,3 +9,14 @@ 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; +} From c6ea72af98bda48b1292d77f3799fba5151638dd Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Mon, 21 Sep 2026 16:36:36 +0200 Subject: [PATCH 05/11] docs(ProgressBar): trim changeset to consumer-facing description Signed-off-by: MartinS-git --- .changeset/progressbar-component.md | 34 +++++++++-------------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/.changeset/progressbar-component.md b/.changeset/progressbar-component.md index d74dc78223..4fa20a8c2d 100644 --- a/.changeset/progressbar-component.md +++ b/.changeset/progressbar-component.md @@ -6,28 +6,14 @@ 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 fake self-running progress that starts with a quick initial -nudge for immediate feedback, then advances through steps separated by -randomized delays and parks near the end, so each run looks like data -trickling in at an uneven pace, for when the final amount of incoming data is -unknown). The determinate fill uses an eased `width` transition so value jumps -animate smoothly, with universal browser support. +`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`, while the `busy` and `simulated` -modes omit it to signal an unknown value to assistive technology. Under -`prefers-reduced-motion: reduce` (WCAG 2.3.3) the decorative and long-running -motion is reduced: the determinate value transition is disabled and the -simulated animation parks statically at its end value. The `busy` animation is -intentionally kept running, because in that indeterminate state the gentle 1.1s -loop is the only signal that work is in progress; freezing it would leave a -static sliver that reads as "stuck". The motion is deliberately low-frequency -and non-flashing, staying within the reduced-motion guidance for essential, -non-decorative status feedback. - -Implementation: the `busy` keyframe animation lives in a -`.juno-progressbar-busy-fill` class in the component-local `progressbar.css` -(consuming the `juno-progress-busy` keyframe by name) rather than as an inline -style, matching the Juno convention. The determinate fill is always rendered -(at `width: 0%` when `value` is 0), and the simulated fill resets on mode -change. +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. From dcdfaf7342eaa3f2eb184ab6e9d72cd0697dec0f Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Wed, 23 Sep 2026 11:25:26 +0200 Subject: [PATCH 06/11] fix(ui): address remaining ProgressBar review findings - guard non-finite value with Number.isFinite before clamping - gate aria-valuenow/valuemin/valuemax together so indeterminate modes expose no value range - precompute simulated step delays via useMemo for deterministic pacing - defensive optional chaining on window.matchMedia - reset simulated width in effect cleanup - restore DT Syntax Highlighting comment header in theme.css - add tests for NaN clamp and missing range attrs in busy/simulated Signed-off-by: MartinS-git --- .changeset/progressbar-component.md | 6 +-- .../ProgressBar/ProgressBar.component.tsx | 39 +++++++++++++------ .../ProgressBar/ProgressBar.test.tsx | 22 +++++++++++ packages/ui-components/src/theme.css | 1 + 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/.changeset/progressbar-component.md b/.changeset/progressbar-component.md index 4fa20a8c2d..899a9c330b 100644 --- a/.changeset/progressbar-component.md +++ b/.changeset/progressbar-component.md @@ -10,9 +10,9 @@ Adds a `ProgressBar` with a `mode` prop offering three modes: `determinate` 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 +Accessibility: uses `role="progressbar"`. The `determinate` mode exposes +`aria-valuenow` alongside `aria-valuemin`/`aria-valuemax`; `busy` and `simulated` +omit all three 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 diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx index fdd603849c..3ed00e5cd5 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx @@ -53,15 +53,33 @@ export const ProgressBar = ({ className = "", ...props }: ProgressBarProps): ReactNode => { - const clampedValue = Math.min(100, Math.max(0, value)) + const safeValue = Number.isFinite(value) ? value : 0 + const clampedValue = Math.min(100, Math.max(0, safeValue)) const indeterminate = mode === "busy" || mode === "simulated" + // Indeterminate modes expose no value range at all, so screen readers announce + // "busy" rather than a bogus 0-100 scale with no current value. + const rangeAttrs = indeterminate + ? {} + : { "aria-valuenow": clampedValue, "aria-valuemin": 0, "aria-valuemax": 100 } + const [simulatedWidth, setSimulatedWidth] = React.useState(0) + // Precompute the step delays once so the randomized pacing stays stable across + // re-renders and StrictMode's double-invoked effect, keeping the run deterministic. + const stepDelays = React.useMemo(() => { + let elapsed = 2700 + return simulatedSteps.map((target) => { + const at = elapsed + elapsed += 4050 + Math.random() * 5400 + return { target, at } + }) + }, []) + React.useEffect(() => { if (mode !== "simulated") return setSimulatedWidth(0) - if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) { + if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) { setSimulatedWidth(simulatedSteps[simulatedSteps.length - 1]) return } @@ -69,20 +87,19 @@ export const ProgressBar = ({ // 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 + stepDelays.forEach(({ target, at }) => { + timers.push(setTimeout(() => setSimulatedWidth(target), at)) }) - return () => timers.forEach(clearTimeout) - }, [mode]) + return () => { + timers.forEach(clearTimeout) + setSimulatedWidth(0) + } + }, [mode, stepDelays]) return (
diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx index 9ad5a97eeb..6e25968db3 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx @@ -133,4 +133,26 @@ describe("ProgressBar component", () => { expect(fill.style.width).toBe("95%") vi.unstubAllGlobals() }) + + test("clamps a non-finite value to 0", () => { + const { container } = render() + const el = screen.getByRole("progressbar") + expect(el).toHaveAttribute("aria-valuenow", "0") + const fill = container.querySelector("[role='progressbar'] > div") as HTMLElement + expect(fill.style.width).toBe("0%") + }) + + test("omits aria-valuemin and aria-valuemax in busy mode", () => { + render() + const el = screen.getByRole("progressbar") + expect(el).not.toHaveAttribute("aria-valuemin") + expect(el).not.toHaveAttribute("aria-valuemax") + }) + + test("omits aria-valuemin and aria-valuemax in simulated mode", () => { + render() + const el = screen.getByRole("progressbar") + expect(el).not.toHaveAttribute("aria-valuemin") + expect(el).not.toHaveAttribute("aria-valuemax") + }) }) diff --git a/packages/ui-components/src/theme.css b/packages/ui-components/src/theme.css index 24bdddbba5..989719f0f6 100644 --- a/packages/ui-components/src/theme.css +++ b/packages/ui-components/src/theme.css @@ -967,6 +967,7 @@ /* 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); /* ? */ --color-syntax-highlight-base02: #bbb; /* lines and boxes */ From 0438bb0700c9c9d8377ab2f5445e5d074f7832ec Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Wed, 23 Sep 2026 11:37:05 +0200 Subject: [PATCH 07/11] style(ui): collapse ProgressBar rangeAttrs to a single line Signed-off-by: MartinS-git --- .../src/components/ProgressBar/ProgressBar.component.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx index 3ed00e5cd5..914902503a 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx @@ -59,9 +59,7 @@ export const ProgressBar = ({ // Indeterminate modes expose no value range at all, so screen readers announce // "busy" rather than a bogus 0-100 scale with no current value. - const rangeAttrs = indeterminate - ? {} - : { "aria-valuenow": clampedValue, "aria-valuemin": 0, "aria-valuemax": 100 } + const rangeAttrs = indeterminate ? {} : { "aria-valuenow": clampedValue, "aria-valuemin": 0, "aria-valuemax": 100 } const [simulatedWidth, setSimulatedWidth] = React.useState(0) From 894b96ab88988998b516072648f3982a2afa0b15 Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Thu, 24 Sep 2026 10:21:33 +0200 Subject: [PATCH 08/11] fix(ui): address ProgressBar review feedback - Add juno-progressbar-{mode} state marker classes to root element - Replace container.querySelector with firstElementChild in tests - Merge first two tests into a single assertion - Add 3 mode-class tests (determinate/busy/simulated) Signed-off-by: MartinS-git --- .../ProgressBar/ProgressBar.component.tsx | 2 +- .../ProgressBar/ProgressBar.test.tsx | 41 ++++++++++++------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx index 914902503a..7323642262 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx @@ -99,7 +99,7 @@ export const ProgressBar = ({ role="progressbar" {...rangeAttrs} aria-label={ariaLabel} - className={`juno-progressbar ${progressBarBaseStyles} ${width} ${className}`} + className={`juno-progressbar juno-progressbar-${mode} ${progressBarBaseStyles} ${width} ${className}`} > {mode === "busy" ? (
diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx index 6e25968db3..222bef021c 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.test.tsx @@ -9,14 +9,11 @@ import { act, render, screen } from "@testing-library/react" import { ProgressBar } from "./" describe("ProgressBar component", () => { - test("renders with role progressbar", () => { + test("renders with role progressbar and base class", () => { render() - expect(screen.getByRole("progressbar")).toBeInTheDocument() - }) - - test("applies juno-progressbar class", () => { - render() - expect(screen.getByRole("progressbar")).toHaveClass("juno-progressbar") + const el = screen.getByRole("progressbar") + expect(el).toBeInTheDocument() + expect(el).toHaveClass("juno-progressbar") }) test("sets aria-valuenow to the provided value", () => { @@ -42,10 +39,10 @@ describe("ProgressBar component", () => { }) test("renders the fill div with width 0% when value is 0", () => { - const { container } = render() + render() const el = screen.getByRole("progressbar") expect(el.children).toHaveLength(1) - const fill = container.querySelector("[role='progressbar'] > div") as HTMLElement + const fill = el.firstElementChild as HTMLElement expect(fill.style.width).toBe("0%") }) @@ -69,6 +66,21 @@ describe("ProgressBar component", () => { expect(screen.getByRole("progressbar")).toHaveClass("custom-class") }) + test("applies juno-progressbar-determinate class by default", () => { + render() + expect(screen.getByRole("progressbar")).toHaveClass("juno-progressbar-determinate") + }) + + test("applies juno-progressbar-busy class in busy mode", () => { + render() + expect(screen.getByRole("progressbar")).toHaveClass("juno-progressbar-busy") + }) + + test("applies juno-progressbar-simulated class in simulated mode", () => { + render() + expect(screen.getByRole("progressbar")).toHaveClass("juno-progressbar-simulated") + }) + test("spreads additional HTML attributes", () => { render() expect(screen.getByTestId("pb")).toHaveAttribute("data-extra", "yes") @@ -86,11 +98,10 @@ describe("ProgressBar component", () => { }) test("renders the determinate fill scaled to value", () => { - const { container } = render() - const fill = container.querySelector("[role='progressbar'] > div") + render() + const fill = screen.getByRole("progressbar").firstElementChild expect(fill).toBeInTheDocument() - const style = (fill as HTMLElement).getAttribute("style") - expect(style).toContain("width: 50%") + expect((fill as HTMLElement).style.width).toBe("50%") }) test("renders simulated indicator in simulated mode", () => { @@ -135,10 +146,10 @@ describe("ProgressBar component", () => { }) test("clamps a non-finite value to 0", () => { - const { container } = render() + render() const el = screen.getByRole("progressbar") expect(el).toHaveAttribute("aria-valuenow", "0") - const fill = container.querySelector("[role='progressbar'] > div") as HTMLElement + const fill = el.firstElementChild as HTMLElement expect(fill.style.width).toBe("0%") }) From e7f7b887a6e8af6beea5b0a6f9ef1d666927ab37 Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Fri, 25 Sep 2026 09:51:37 +0200 Subject: [PATCH 09/11] fix(ui): rebuild ProgressBar busy animation with transform and round fill ends - Switch keyframes from margin-left + width to translateX + scaleX for GPU-composited animation without layout reflow - Add will-change: transform and transform-origin: 0 50% to busy fill - Round busy fill ends with rounded-full (was rounded-xl) Signed-off-by: MartinS-git --- .../ProgressBar/ProgressBar.component.tsx | 2 +- .../src/components/ProgressBar/progressbar.css | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx index 7323642262..e4dd57e3cf 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx @@ -102,7 +102,7 @@ export const ProgressBar = ({ className={`juno-progressbar juno-progressbar-${mode} ${progressBarBaseStyles} ${width} ${className}`} > {mode === "busy" ? ( -
+
) : mode === "simulated" ? (
Date: Fri, 25 Sep 2026 09:53:14 +0200 Subject: [PATCH 10/11] fix(ui): add missing argTypes and Playground story to ProgressBar Add explicit controls for aria-label, width, and className, and restore the Playground story with all props as defaults. Signed-off-by: MartinS-git --- .../ProgressBar/ProgressBar.stories.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.stories.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.stories.tsx index 13de3f2474..9fcb246693 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.stories.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.stories.tsx @@ -17,6 +17,15 @@ const meta: Meta = { control: { type: "select" }, options: ["determinate", "busy", "simulated"], }, + "aria-label": { + control: { type: "text" }, + }, + width: { + control: { type: "text" }, + }, + className: { + control: { type: "text" }, + }, }, } @@ -47,3 +56,13 @@ export const Simulated: Story = { mode: "simulated", }, } + +export const Playground: Story = { + args: { + value: 50, + mode: "determinate", + "aria-label": "Progress", + width: "jn:w-44", + className: "", + }, +} From 89dc2aa1e9f1944d17c6bdd9186e7fc560658911 Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Fri, 25 Sep 2026 10:34:12 +0200 Subject: [PATCH 11/11] fix(ui): use clip-path animation for ProgressBar busy mode scaleX compresses border-radius proportionally (CSS spec) making rounded ends invisible at small values. clip-path: inset() with round 9999px creates a pill that grows, shrinks and slides without touching the transform stack, so corners stay fully rounded throughout the entire animation cycle. Signed-off-by: MartinS-git --- .../components/ProgressBar/ProgressBar.component.tsx | 2 +- .../src/components/ProgressBar/progressbar.css | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx index e4dd57e3cf..dade996806 100644 --- a/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx +++ b/packages/ui-components/src/components/ProgressBar/ProgressBar.component.tsx @@ -102,7 +102,7 @@ export const ProgressBar = ({ className={`juno-progressbar juno-progressbar-${mode} ${progressBarBaseStyles} ${width} ${className}`} > {mode === "busy" ? ( -
+
) : mode === "simulated" ? (