diff --git a/src/components/Pega_Extensions_ProgressBar/Docs.mdx b/src/components/Pega_Extensions_ProgressBar/Docs.mdx
new file mode 100644
index 00000000..8dec0ef3
--- /dev/null
+++ b/src/components/Pega_Extensions_ProgressBar/Docs.mdx
@@ -0,0 +1,30 @@
+import { Meta, Primary, Controls, Story } from '@storybook/addon-docs/blocks';
+import * as DemoStories from './demo.stories';
+
+
+
+# Progress Bar
+
+`Pega_Extensions_ProgressBar` is a theme-aware, accessible progress widget built for jobs that report rapid, frequent progress — for example once a second — rather than slow-moving milestones. It has no bound property of its own — it reads its `value`/`max` from the configured `dataPage`, and the PCore messaging service is the only mechanism that triggers a refresh.
+
+On mount, the widget fetches `dataPage` via `PCore.getDataApiUtils().getData()` and subscribes to case updates with `PCore.getMessagingServiceManager().subscribe({ matcher: 'CASE', criteria: { caseId } }, ...)`. Every time the messaging service reports the case changed, the widget refetches the data page, so it can keep up with updates arriving multiple times per second; it unsubscribes on unmount. Until the first fetch resolves, the widget renders an indeterminate state.
+
+The data page can also drive the visual tone: if the record includes a valid `toneProperty` value (`accent`, `success`, `warning`, or `danger`), it overrides the static `tone` prop — useful for flipping to `danger` the moment a job reports a failure.
+
+Set `indeterminateOnly` to skip the data page and messaging service entirely and just show a never-ending progress animation — useful when work is happening but there's nothing to track it against.
+
+
+
+## Props
+
+
+
+## Examples
+
+
+
+
+
+
+
+
diff --git a/src/components/Pega_Extensions_ProgressBar/config.json b/src/components/Pega_Extensions_ProgressBar/config.json
new file mode 100644
index 00000000..890ae9ab
--- /dev/null
+++ b/src/components/Pega_Extensions_ProgressBar/config.json
@@ -0,0 +1,183 @@
+{
+ "name": "Pega_Extensions_ProgressBar",
+ "label": "Progress Bar",
+ "description": "Accessible progress widget for jobs that push rapid updates (for example once a second); it reads its value from a data page and refreshes only in response to PCore messaging service updates, never on a timer or by polling.",
+ "organization": "Pega",
+ "version": "5.0.4",
+ "library": "Extensions",
+ "allowedApplications": [],
+ "componentKey": "Pega_Extensions_ProgressBar",
+ "type": "Widget",
+ "subtype": [
+ "PAGE",
+ "CASE"
+ ],
+ "properties": [
+ {
+ "name": "label",
+ "label": "Label",
+ "format": "TEXT",
+ "required": true
+ },
+ {
+ "name": "indeterminateOnly",
+ "label": "Indeterminate only",
+ "format": "BOOLEAN",
+ "defaultValue": false
+ },
+ {
+ "name": "dataPage",
+ "label": "Data page name",
+ "format": "TEXT",
+ "visibility": "($this.indeterminateOnly = false)"
+ },
+ {
+ "name": "valueProperty",
+ "label": "Value property name",
+ "format": "TEXT",
+ "defaultValue": "Value",
+ "visibility": "($this.indeterminateOnly = false)"
+ },
+ {
+ "name": "minProperty",
+ "label": "Minimum property name",
+ "format": "TEXT",
+ "defaultValue": "Min",
+ "visibility": "($this.indeterminateOnly = false)"
+ },
+ {
+ "name": "maxProperty",
+ "label": "Maximum property name",
+ "format": "TEXT",
+ "defaultValue": "Max",
+ "visibility": "($this.indeterminateOnly = false)"
+ },
+ {
+ "name": "toneProperty",
+ "label": "Tone property name",
+ "format": "TEXT",
+ "defaultValue": "Tone",
+ "visibility": "($this.indeterminateOnly = false)"
+ },
+ {
+ "name": "tone",
+ "label": "Default progress tone",
+ "format": "SELECT",
+ "defaultValue": "accent",
+ "source": [
+ {
+ "key": "accent",
+ "value": "Accent"
+ },
+ {
+ "key": "success",
+ "value": "Success"
+ },
+ {
+ "key": "warning",
+ "value": "Warning"
+ },
+ {
+ "key": "danger",
+ "value": "Danger"
+ }
+ ]
+ },
+ {
+ "name": "size",
+ "label": "Bar size",
+ "format": "SELECT",
+ "defaultValue": "regular",
+ "source": [
+ {
+ "key": "compact",
+ "value": "Compact"
+ },
+ {
+ "key": "regular",
+ "value": "Regular"
+ },
+ {
+ "key": "large",
+ "value": "Large"
+ }
+ ]
+ },
+ {
+ "name": "showValue",
+ "label": "Show value",
+ "format": "BOOLEAN",
+ "defaultValue": true,
+ "visibility": "($this.indeterminateOnly = false)"
+ },
+ {
+ "name": "showMarkers",
+ "label": "Show milestone markers",
+ "format": "BOOLEAN",
+ "defaultValue": true,
+ "visibility": "($this.indeterminateOnly = false)"
+ },
+ {
+ "label": "Input settings",
+ "format": "GROUP",
+ "properties": [
+ {
+ "name": "helperText",
+ "label": "Helper text",
+ "format": "TEXT"
+ }
+ ]
+ },
+ {
+ "label": "Conditions",
+ "format": "GROUP",
+ "properties": [
+ {
+ "name": "visibility",
+ "label": "Visibility",
+ "format": "VISIBILITY"
+ }
+ ]
+ },
+ {
+ "label": "Advanced",
+ "format": "GROUP",
+ "collapsible": true,
+ "properties": [
+ {
+ "name": "testId",
+ "label": "Test ID",
+ "format": "TEXT",
+ "ignorePattern": "[^-_\\p{N}\\p{L}]",
+ "includeAnnotations": false
+ }
+ ]
+ },
+ {
+ "format": "LABEL",
+ "label": "About",
+ "name": "PegaAboutLabel",
+ "variant": "h3"
+ },
+ {
+ "format": "LABEL",
+ "label": "Pega_Extensions 5.0.4",
+ "name": "PegaAboutData",
+ "variant": "primary"
+ }
+ ],
+ "defaultConfig": {
+ "label": "@L $this.label",
+ "indeterminateOnly": false,
+ "valueProperty": "Value",
+ "minProperty": "Min",
+ "maxProperty": "Max",
+ "toneProperty": "Tone",
+ "tone": "accent",
+ "size": "regular",
+ "showValue": true,
+ "showMarkers": true
+ },
+ "infinityVersion": "26.1.1",
+ "packageCosmosVersion": "9.25.0"
+}
diff --git a/src/components/Pega_Extensions_ProgressBar/demo.stories.tsx b/src/components/Pega_Extensions_ProgressBar/demo.stories.tsx
new file mode 100644
index 00000000..5da5ee87
--- /dev/null
+++ b/src/components/Pega_Extensions_ProgressBar/demo.stories.tsx
@@ -0,0 +1,141 @@
+import type { Meta, StoryObj } from '@storybook/react-webpack5';
+
+import { configProps, rapidProgressSteps, mockCompleteDataPageResponse } from './mock';
+import { PegaExtensionsProgressBar, type PegaExtensionsProgressBarProps } from './index';
+
+type StoryArgs = Omit;
+
+const renderProgressBar = (args: StoryArgs) => {
+ const props = {
+ ...args,
+ getPConnect: () =>
+ ({
+ getValue: () => 'WORK-1',
+ getLocalizedValue: (text: string) => text,
+ getContextName: () => 'primary',
+ }) as unknown as typeof PConnect,
+ };
+
+ return ;
+};
+
+/* Fake PCore so the widget can fetch and subscribe via the messaging service in Storybook */
+const stubPCore = (getData: () => Promise) => {
+ window.PCore = {
+ getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }),
+ getDataApiUtils: () => ({ getData }),
+ getMessagingServiceManager: () => ({
+ subscribe: () => 'subscription-id',
+ unsubscribe: () => {},
+ }),
+ } as unknown as typeof PCore;
+};
+
+/* Simulates the messaging service pushing a rapid succession of progress updates, roughly once a second */
+const stubPCoreWithRapidUpdates = (steps: number[], intervalMs = 1000) => {
+ let step = 0;
+ window.PCore = {
+ getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }),
+ getDataApiUtils: () => ({
+ getData: () => Promise.resolve({ data: { data: [{ Value: steps[step], Max: 100 }] } }),
+ }),
+ getMessagingServiceManager: () => ({
+ subscribe: (_filter: unknown, handler: () => void) => {
+ const intervalId = window.setInterval(() => {
+ if (step < steps.length - 1) step += 1;
+ handler();
+ }, intervalMs);
+ return String(intervalId);
+ },
+ unsubscribe: (subscriptionId: string) => window.clearInterval(Number(subscriptionId)),
+ }),
+ } as unknown as typeof PCore;
+};
+
+const StoryComponent = (args: StoryArgs) => {
+ stubPCoreWithRapidUpdates(rapidProgressSteps);
+ return renderProgressBar(args);
+};
+
+const meta = {
+ title: 'Widgets/Progress Bar',
+ component: StoryComponent,
+ argTypes: {
+ tone: {
+ options: ['accent', 'success', 'warning', 'danger'],
+ control: {
+ type: 'select',
+ },
+ },
+ size: {
+ options: ['compact', 'regular', 'large'],
+ control: {
+ type: 'inline-radio',
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => {
+ stubPCoreWithRapidUpdates(rapidProgressSteps);
+ return renderProgressBar(args);
+ },
+ args: configProps,
+};
+
+export const Complete: Story = {
+ render: (args) => {
+ stubPCore(() => Promise.resolve(mockCompleteDataPageResponse));
+ return renderProgressBar(args);
+ },
+ args: {
+ ...configProps,
+ label: 'Backup job progress',
+ tone: 'success',
+ helperText: 'The backup job finished processing all records.',
+ },
+};
+
+export const AtRisk: Story = {
+ render: (args) => {
+ stubPCore(() => Promise.resolve({ data: { data: [{ Value: 42, Max: 100 }] } }));
+ return renderProgressBar(args);
+ },
+ args: {
+ ...configProps,
+ label: 'Sync job progress',
+ tone: 'warning',
+ size: 'large',
+ helperText: 'Updates have slowed; the sync job may be stalled.',
+ },
+};
+
+export const Loading: Story = {
+ render: (args) => {
+ /* Never resolves, so the widget stays indeterminate until the messaging service delivers the first update */
+ stubPCore(() => new Promise(() => {}));
+ return renderProgressBar(args);
+ },
+ args: {
+ ...configProps,
+ label: 'Import job progress',
+ helperText: 'Waiting for the import job to report its first update.',
+ },
+};
+
+export const Continuous: Story = {
+ /* indeterminateOnly mode never fetches or subscribes */
+ render: (args) => renderProgressBar(args),
+ args: {
+ ...configProps,
+ label: 'Indexing job',
+ indeterminateOnly: true,
+ dataPage: undefined,
+ helperText: 'Runs continuously while the indexing job is in progress.',
+ },
+};
diff --git a/src/components/Pega_Extensions_ProgressBar/demo.test.tsx b/src/components/Pega_Extensions_ProgressBar/demo.test.tsx
new file mode 100644
index 00000000..8138ec25
--- /dev/null
+++ b/src/components/Pega_Extensions_ProgressBar/demo.test.tsx
@@ -0,0 +1,205 @@
+import { render, screen } from '@testing-library/react';
+import { composeStories } from '@storybook/react-webpack5';
+import '@testing-library/jest-dom';
+
+import * as DemoStories from './demo.stories';
+import { PegaExtensionsProgressBar } from './index';
+
+const { Default, Complete, AtRisk, Loading, Continuous } = composeStories(DemoStories);
+
+test('loads its first value from the data page and renders accessible values', async () => {
+ render();
+
+ const progress = await screen.findByRole('progressbar', { name: 'Export job progress' });
+ expect(progress).toHaveAttribute('aria-valuenow', '12');
+ expect(progress).toHaveAttribute('aria-valuemax', '100');
+ expect(screen.getByText('12%')).toBeVisible();
+ expect(screen.getByText('On track')).toBeVisible();
+ expect(screen.getByTestId('ProgressBar-12345678:marker:50')).toBeInTheDocument();
+});
+
+test('renders completion state', async () => {
+ render();
+
+ const progress = await screen.findByRole('progressbar', { name: 'Backup job progress' });
+ expect(progress).toHaveAttribute('aria-valuenow', '100');
+ expect(progress).toHaveAttribute('aria-valuetext', '100% complete');
+ expect(screen.getAllByText('Complete').length).toBeGreaterThan(0);
+});
+
+test('renders an at-risk state', async () => {
+ render();
+
+ const progress = await screen.findByRole('progressbar', { name: 'Sync job progress' });
+ expect(progress).toHaveAttribute('aria-valuenow', '42');
+ expect(screen.getByText('In review')).toBeVisible();
+});
+
+test('stays indeterminate until the data page resolves', () => {
+ render();
+
+ const progress = screen.getByRole('progressbar', { name: 'Import job progress' });
+ expect(progress).not.toHaveAttribute('aria-valuenow');
+ expect(progress).toHaveAttribute('aria-valuetext', 'In progress');
+ expect(screen.queryByTestId('ProgressBar-12345678:marker:50')).not.toBeInTheDocument();
+});
+
+test('runs continuously with no data page or PCore involved', () => {
+ delete (window as { PCore?: typeof PCore }).PCore;
+
+ render();
+
+ const progress = screen.getByRole('progressbar', { name: 'Indexing job' });
+ expect(progress).not.toHaveAttribute('aria-valuenow');
+ expect(progress).toHaveAttribute('aria-valuetext', 'In progress');
+});
+
+test('offsets progress from a non-zero minimum reported by the data page', async () => {
+ window.PCore = {
+ getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }),
+ getDataApiUtils: () => ({
+ getData: () => Promise.resolve({ data: { data: [{ Value: 50, Min: 20, Max: 100 }] } }),
+ }),
+ getMessagingServiceManager: () => ({
+ subscribe: () => 'subscription-id',
+ unsubscribe: () => {},
+ }),
+ } as unknown as typeof PCore;
+
+ const getPConnect = () =>
+ ({
+ getValue: () => 'WORK-1',
+ getLocalizedValue: (text: string) => text,
+ getContextName: () => 'primary',
+ }) as unknown as typeof PConnect;
+
+ render(
+ ,
+ );
+
+ const progress = await screen.findByRole('progressbar', { name: 'Gauge progress' });
+ expect(progress).toHaveAttribute('aria-valuemin', '20');
+ expect(progress).toHaveAttribute('aria-valuemax', '100');
+ expect(progress).toHaveAttribute('aria-valuenow', '50');
+ expect(await screen.findByText('38%')).toBeVisible();
+});
+
+test('lets the data page override the static tone', async () => {
+ window.PCore = {
+ getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }),
+ getDataApiUtils: () => ({
+ getData: () => Promise.resolve({ data: { data: [{ Value: 30, Max: 100, Tone: 'danger' }] } }),
+ }),
+ getMessagingServiceManager: () => ({
+ subscribe: () => 'subscription-id',
+ unsubscribe: () => {},
+ }),
+ } as unknown as typeof PCore;
+
+ const getPConnect = () =>
+ ({
+ getValue: () => 'WORK-1',
+ getLocalizedValue: (text: string) => text,
+ getContextName: () => 'primary',
+ }) as unknown as typeof PConnect;
+
+ render(
+ ,
+ );
+
+ expect(await screen.findByText('Needs attention')).toBeVisible();
+});
+
+test('ignores an invalid tone reported by the data page', async () => {
+ window.PCore = {
+ getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }),
+ getDataApiUtils: () => ({
+ getData: () => Promise.resolve({ data: { data: [{ Value: 30, Max: 100, Tone: 'not-a-tone' }] } }),
+ }),
+ getMessagingServiceManager: () => ({
+ subscribe: () => 'subscription-id',
+ unsubscribe: () => {},
+ }),
+ } as unknown as typeof PCore;
+
+ const getPConnect = () =>
+ ({
+ getValue: () => 'WORK-1',
+ getLocalizedValue: (text: string) => text,
+ getContextName: () => 'primary',
+ }) as unknown as typeof PConnect;
+
+ render(
+ ,
+ );
+
+ expect(await screen.findByText('On track')).toBeVisible();
+});
+
+test('subscribes to and unsubscribes from the PCore messaging service', () => {
+ const subscribe = jest.fn(() => 'subscription-id');
+ const unsubscribe = jest.fn();
+ window.PCore = {
+ getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }),
+ getDataApiUtils: () => ({
+ getData: () => Promise.resolve({ data: { data: [{ Value: 10, Max: 100 }] } }),
+ }),
+ getMessagingServiceManager: () => ({
+ subscribe,
+ unsubscribe,
+ }),
+ } as unknown as typeof PCore;
+
+ const getPConnect = () =>
+ ({
+ getValue: () => 'WORK-1',
+ getLocalizedValue: (text: string) => text,
+ getContextName: () => 'primary',
+ }) as unknown as typeof PConnect;
+
+ const { unmount } = render(
+ ,
+ );
+
+ expect(subscribe).toHaveBeenCalledWith(
+ { matcher: 'CASE', criteria: { caseId: 'WORK-1' } },
+ expect.any(Function),
+ 'primary',
+ );
+
+ unmount();
+
+ expect(unsubscribe).toHaveBeenCalledWith('subscription-id');
+});
+
+test('subscribes to data page updates when used on a page without a case context', () => {
+ const subscribe = jest.fn(() => 'subscription-id');
+ const unsubscribe = jest.fn();
+ window.PCore = {
+ getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }),
+ getDataApiUtils: () => ({
+ getData: () => Promise.resolve({ data: { data: [{ Value: 10, Max: 100 }] } }),
+ }),
+ getMessagingServiceManager: () => ({
+ subscribe,
+ unsubscribe,
+ }),
+ } as unknown as typeof PCore;
+
+ const getPConnect = () =>
+ ({
+ getValue: () => undefined,
+ getLocalizedValue: (text: string) => text,
+ getContextName: () => 'primary',
+ }) as unknown as typeof PConnect;
+
+ render(
+ ,
+ );
+
+ expect(subscribe).toHaveBeenCalledWith(
+ { matcher: 'DATAPAGE_UPDATED', criteria: { datapage: 'D_ExportJobProgress' } },
+ expect.any(Function),
+ 'primary',
+ );
+});
diff --git a/src/components/Pega_Extensions_ProgressBar/index.tsx b/src/components/Pega_Extensions_ProgressBar/index.tsx
new file mode 100644
index 00000000..a6cdc0c2
--- /dev/null
+++ b/src/components/Pega_Extensions_ProgressBar/index.tsx
@@ -0,0 +1,185 @@
+import { createUID, Text, withConfiguration } from '@pega/cosmos-react-core';
+import { useCallback, useEffect, useState } from 'react';
+
+import '../shared/create-nonce';
+
+import {
+ StyledProgressBar,
+ StyledProgressFill,
+ StyledProgressHeader,
+ StyledProgressLegend,
+ StyledProgressMarker,
+ StyledProgressMarkers,
+ StyledProgressRail,
+ StyledProgressStatus,
+ StyledProgressTrack,
+ StyledProgressValue,
+} from './styles';
+import { getPercentage, getProgressStatus, isProgressTone, normalizeProgress, type ProgressSize, type ProgressTone } from './utils';
+
+export interface PegaExtensionsProgressBarProps {
+ /** Widget label. */
+ label: string;
+ /** Shows a never-ending progress animation, skipping the data page and messaging service entirely. */
+ indeterminateOnly?: boolean;
+ /** Name of the data page providing the progress value. Refreshed only via the PCore messaging service. Ignored when `indeterminateOnly` is set. */
+ dataPage?: string;
+ /** Property in the data page response holding the current progress value. */
+ valueProperty?: string;
+ /** Property in the data page response holding the minimum progress value. */
+ minProperty?: string;
+ /** Property in the data page response holding the maximum progress value. */
+ maxProperty?: string;
+ /** Property in the data page response holding the progress tone. Overrides the static `tone` prop when valid. */
+ toneProperty?: string;
+ /** Supporting text shown below the label. */
+ helperText?: string;
+ /** Test identifier. */
+ testId?: string;
+ /** Visual tone for the progress fill. */
+ tone?: ProgressTone;
+ /** Track height. */
+ size?: ProgressSize;
+ /** Shows the percentage or in-progress label. */
+ showValue?: boolean;
+ /** Shows milestone markers at 25%, 50%, and 75%. */
+ showMarkers?: boolean;
+ getPConnect: () => typeof PConnect;
+}
+
+export function PegaExtensionsProgressBar(props: PegaExtensionsProgressBarProps) {
+ const {
+ label,
+ indeterminateOnly = false,
+ dataPage,
+ valueProperty = 'Value',
+ minProperty = 'Min',
+ maxProperty = 'Max',
+ toneProperty = 'Tone',
+ helperText,
+ testId,
+ tone = 'accent',
+ size = 'regular',
+ showValue = true,
+ showMarkers = true,
+ getPConnect,
+ } = props;
+
+ const pConn = getPConnect();
+ const localize = (text: string) => pConn.getLocalizedValue?.(text) || text;
+ const [id] = useState(() => createUID());
+ const [value, setValue] = useState(undefined);
+ const [min, setMin] = useState(undefined);
+ const [max, setMax] = useState(undefined);
+ const [dataPageTone, setDataPageTone] = useState(undefined);
+
+ const loadFromDataPage = useCallback(() => {
+ if (indeterminateOnly || !dataPage) return;
+ PCore.getDataApiUtils()
+ .getData(dataPage, {}, getPConnect().getContextName())
+ .then((response: any) => {
+ const data = response?.data?.data;
+ const record = Array.isArray(data) ? data[0] : data;
+ if (record) {
+ if (record[valueProperty] !== undefined) setValue(Number(record[valueProperty]));
+ if (record[minProperty] !== undefined) setMin(Number(record[minProperty]));
+ if (record[maxProperty] !== undefined) setMax(Number(record[maxProperty]));
+ if (isProgressTone(record[toneProperty])) setDataPageTone(record[toneProperty]);
+ }
+ })
+ .catch(() => {});
+ }, [indeterminateOnly, dataPage, valueProperty, minProperty, maxProperty, toneProperty, getPConnect]);
+
+ /* The PCore messaging service is the only trigger for refreshing this widget's progress. */
+ useEffect(() => {
+ if (indeterminateOnly || !dataPage) return undefined;
+ /* On a case, listen for that case's updates; on a page (no case context), listen for the data page itself */
+ let caseID: string | undefined;
+ try {
+ caseID = getPConnect().getValue(PCore.getConstants().CASE_INFO.CASE_INFO_ID);
+ } catch {
+ caseID = undefined;
+ }
+ const filter: { matcher: string; criteria: Record } = caseID
+ ? { matcher: 'CASE', criteria: { caseId: caseID } }
+ : { matcher: 'DATAPAGE_UPDATED', criteria: { datapage: dataPage } };
+ const subscriptionId = PCore.getMessagingServiceManager().subscribe(
+ filter,
+ loadFromDataPage,
+ getPConnect().getContextName(),
+ );
+ loadFromDataPage();
+ return () => {
+ PCore.getMessagingServiceManager().unsubscribe(subscriptionId);
+ };
+ }, [indeterminateOnly, dataPage, loadFromDataPage, getPConnect]);
+
+ const indeterminate = indeterminateOnly || value === undefined;
+ const effectiveTone = dataPageTone ?? tone;
+ const normalizedMin = Number.isFinite(min) ? (min as number) : 0;
+ const normalizedMax = Number.isFinite(max) && (max as number) > normalizedMin ? (max as number) : normalizedMin + 100;
+ const normalizedValue = normalizeProgress(value ?? normalizedMin, normalizedMin, normalizedMax);
+ const percentage = getPercentage(normalizedValue, normalizedMin, normalizedMax);
+ const progressText = percentage === 100 ? localize('Complete') : `${percentage}%`;
+ const statusText = localize(getProgressStatus(percentage, effectiveTone));
+ const ariaValueText = indeterminate ? localize('In progress') : `${percentage}% ${localize('complete')}`;
+ const progressId = `${id}-progress`;
+ const helperId = `${id}-helper`;
+ const markers = [25, 50, 75];
+
+ return (
+
+ {label}
+ {helperText && (
+
+ {helperText}
+
+ )}
+ {showValue && !indeterminate && (
+
+ {statusText}
+ {progressText}
+
+ )}
+
+
+
+ {showMarkers && !indeterminate && (
+
+ {markers.map((marker) => (
+ = marker}
+ data-testid={testId ? `${testId}:marker:${marker}` : undefined}
+ style={{ left: `${marker}%` }}
+ />
+ ))}
+
+ )}
+
+ {showMarkers && !indeterminate && (
+
+ {localize('Complete')}
+
+ )}
+
+
+ );
+}
+
+export default withConfiguration(PegaExtensionsProgressBar);
diff --git a/src/components/Pega_Extensions_ProgressBar/mock.ts b/src/components/Pega_Extensions_ProgressBar/mock.ts
new file mode 100644
index 00000000..d3670903
--- /dev/null
+++ b/src/components/Pega_Extensions_ProgressBar/mock.ts
@@ -0,0 +1,28 @@
+export const configProps = {
+ label: 'Export job progress',
+ dataPage: 'D_ExportJobProgress',
+ valueProperty: 'Value',
+ maxProperty: 'Max',
+ helperText: 'Progress is streamed by the export job several times a second.',
+ testId: 'ProgressBar-12345678',
+ tone: 'accent' as const,
+ size: 'regular' as const,
+ showValue: true,
+ showMarkers: true,
+};
+
+/* Successive values the export job reports as it streams rapid, roughly once-a-second progress updates */
+export const rapidProgressSteps = [12, 27, 41, 58, 73, 89, 100];
+
+export const mockCompleteDataPageResponse = {
+ data: {
+ data: [
+ {
+ Value: 100,
+ Max: 100,
+ },
+ ],
+ },
+ status: 200,
+ statusText: '',
+};
diff --git a/src/components/Pega_Extensions_ProgressBar/styles.ts b/src/components/Pega_Extensions_ProgressBar/styles.ts
new file mode 100644
index 00000000..557a0c38
--- /dev/null
+++ b/src/components/Pega_Extensions_ProgressBar/styles.ts
@@ -0,0 +1,265 @@
+import styled, { css, keyframes } from 'styled-components';
+import type { DefaultTheme } from 'styled-components';
+import { defaultThemeProp } from '@pega/cosmos-react-core';
+import type { ProgressSize, ProgressTone } from './utils';
+
+const lightSweep = keyframes`
+ from {
+ transform: translateX(-140%);
+ }
+
+ to {
+ transform: translateX(520%);
+ }
+`;
+
+const glowBreath = keyframes`
+ 0%,
+ 100% {
+ filter: brightness(1);
+ }
+
+ 50% {
+ filter: brightness(1.12);
+ }
+`;
+
+const completionFlash = keyframes`
+ 0% {
+ filter: brightness(1);
+ transform: scaleY(1);
+ }
+
+ 35% {
+ filter: brightness(1.3);
+ transform: scaleY(1.35);
+ }
+
+ 100% {
+ filter: brightness(1);
+ transform: scaleY(1);
+ }
+`;
+
+const stripeDrift = keyframes`
+ from {
+ background-position: 0 0;
+ }
+
+ to {
+ background-position: 1rem 1rem;
+ }
+`;
+
+const getTrackHeight = (size: ProgressSize, spacing: string): string => {
+ if (size === 'compact') {
+ return `calc(${spacing} * 0.5)`;
+ }
+
+ if (size === 'large') {
+ return `calc(${spacing} * 1.5)`;
+ }
+
+ return spacing;
+};
+
+const getToneColor = (tone: ProgressTone, theme: DefaultTheme): string => {
+ const colors: Record = {
+ accent: theme.base.palette.interactive,
+ success: theme.base.palette.success,
+ warning: theme.base.palette.warn,
+ danger: theme.base.palette.urgent,
+ };
+
+ return colors[tone];
+};
+
+export const StyledProgressBar = styled.div(({ theme }) => {
+ return css`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.base.spacing};
+ width: 100%;
+ `;
+});
+
+StyledProgressBar.defaultProps = defaultThemeProp;
+
+export const StyledProgressHeader = styled.div(({ theme }) => {
+ return css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: ${theme.base.spacing};
+ min-height: calc(${theme.base.spacing} * 1.25);
+ `;
+});
+
+StyledProgressHeader.defaultProps = defaultThemeProp;
+
+export const StyledProgressValue = styled.span(({ theme }) => {
+ return css`
+ min-width: 3.5rem;
+ padding: calc(${theme.base.spacing} * 0.375) calc(${theme.base.spacing} * 0.75);
+ border: 0.0625rem solid ${theme.base.palette['border-line']};
+ border-radius: ${theme.base['border-radius']};
+ background: ${theme.base.palette['secondary-background']};
+ color: ${theme.base.palette['foreground-color']};
+ font-size: ${theme.base['font-size']};
+ font-weight: ${theme.base['font-weight']['semi-bold']};
+ text-align: center;
+ `;
+});
+
+StyledProgressValue.defaultProps = defaultThemeProp;
+
+export const StyledProgressStatus = styled.span<{ $tone: ProgressTone }>(({ theme, $tone }) => {
+ const toneColor = getToneColor($tone, theme);
+
+ return css`
+ display: inline-flex;
+ align-items: center;
+ gap: calc(${theme.base.spacing} * 0.5);
+ color: ${theme.base.palette['foreground-color']};
+ font-size: ${theme.base['font-size']};
+
+ &::before {
+ width: calc(${theme.base.spacing} * 0.625);
+ height: calc(${theme.base.spacing} * 0.625);
+ border-radius: 50%;
+ background: ${toneColor};
+ box-shadow: 0 0 calc(${theme.base.spacing} * 0.5) ${toneColor};
+ content: '';
+ }
+ `;
+});
+
+StyledProgressStatus.defaultProps = defaultThemeProp;
+
+export const StyledProgressRail = styled.div(({ theme }) => {
+ return css`
+ display: flex;
+ flex-direction: column;
+ gap: calc(${theme.base.spacing} * 0.5);
+ width: 100%;
+ `;
+});
+
+StyledProgressRail.defaultProps = defaultThemeProp;
+
+export const StyledProgressTrack = styled.div<{ $size: ProgressSize }>(({ theme, $size }) => {
+ return css`
+ position: relative;
+ overflow: hidden;
+ width: 100%;
+ height: ${getTrackHeight($size, theme.base.spacing)};
+ border: 0.0625rem solid ${theme.base.palette['border-line']};
+ border-radius: ${theme.base['border-radius']};
+ background: ${theme.base.palette['secondary-background']};
+ box-shadow: inset 0 0.125rem 0.25rem rgb(0 0 0 / 0.08);
+ `;
+});
+
+StyledProgressTrack.defaultProps = defaultThemeProp;
+
+export const StyledProgressMarkers = styled.div(() => {
+ return css`
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ pointer-events: none;
+ `;
+});
+
+StyledProgressMarkers.defaultProps = defaultThemeProp;
+
+export const StyledProgressLegend = styled.div(({ theme }) => {
+ return css`
+ display: flex;
+ justify-content: flex-end;
+ color: ${theme.base.palette['foreground-color']};
+ font-size: calc(${theme.base['font-size']} * 0.875);
+ opacity: ${theme.base.transparency['transparent-2']};
+ `;
+});
+
+StyledProgressLegend.defaultProps = defaultThemeProp;
+
+export const StyledProgressMarker = styled.span<{ $active: boolean }>(({ theme, $active }) => {
+ return css`
+ position: absolute;
+ inset-block: 0;
+ width: 0.0625rem;
+ background: ${theme.base.palette['primary-background']};
+ opacity: ${$active ? 0.7 : 0.32};
+ transform: translateX(-50%);
+ `;
+});
+
+StyledProgressMarker.defaultProps = defaultThemeProp;
+
+export const StyledProgressFill = styled.div<{
+ $complete: boolean;
+ $indeterminate: boolean;
+ $percentage: number;
+ $tone: ProgressTone;
+}>(({ theme, $complete, $indeterminate, $percentage, $tone }) => {
+ return css`
+ position: relative;
+ overflow: hidden;
+ width: ${$indeterminate ? '100%' : `${$percentage}%`};
+ height: 100%;
+ border-radius: inherit;
+ background: ${getToneColor($tone, theme)};
+ box-shadow: 0 0 calc(${theme.base.spacing} * 0.75) ${getToneColor($tone, theme)};
+ transition: width 420ms cubic-bezier(0.22, 1, 0.36, 1);
+
+ &::after {
+ position: absolute;
+ inset-block: 0;
+ left: 0;
+ width: 28%;
+ background: ${theme.base.palette['primary-background']};
+ content: '';
+ opacity: 0.24;
+ transform: translateX(-120%);
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ animation: ${$complete ? completionFlash : glowBreath} ${$complete ? '900ms' : '2.8s'} ease-in-out
+ ${$complete ? '1' : 'infinite'};
+
+ &::after {
+ animation: ${lightSweep} ${$indeterminate ? '1.6s' : '2.6s'} cubic-bezier(0.4, 0, 0.2, 1) infinite;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+
+ /* translucent texture on the same tone color (not a moving sweep or a contrasting stripe) keeps this readable and consistent with the solid fill look */
+ background: ${$indeterminate
+ ? css`repeating-linear-gradient(
+ 135deg,
+ rgb(255 255 255 / 18%) 25%,
+ transparent 25%,
+ transparent 50%,
+ rgb(255 255 255 / 18%) 50%,
+ rgb(255 255 255 / 18%) 75%,
+ transparent 75%,
+ transparent
+ ),
+ ${getToneColor($tone, theme)}`
+ : getToneColor($tone, theme)};
+ background-size: ${$indeterminate ? '1rem 1rem' : 'auto'};
+ animation: ${$indeterminate ? css`${stripeDrift} 900ms linear infinite` : 'none'};
+
+ &::after {
+ animation: none;
+ opacity: 0;
+ }
+ }
+ `;
+});
+
+StyledProgressFill.defaultProps = defaultThemeProp;
diff --git a/src/components/Pega_Extensions_ProgressBar/utils.ts b/src/components/Pega_Extensions_ProgressBar/utils.ts
new file mode 100644
index 00000000..abd43726
--- /dev/null
+++ b/src/components/Pega_Extensions_ProgressBar/utils.ts
@@ -0,0 +1,39 @@
+export type ProgressTone = 'accent' | 'success' | 'warning' | 'danger';
+export type ProgressSize = 'compact' | 'regular' | 'large';
+
+const progressTones: ProgressTone[] = ['accent', 'success', 'warning', 'danger'];
+
+export const isProgressTone = (value: unknown): value is ProgressTone =>
+ progressTones.includes(value as ProgressTone);
+
+export const normalizeProgress = (value: number, min: number, max: number): number => {
+ if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
+ return min;
+ }
+
+ return Math.min(max, Math.max(min, value));
+};
+
+export const getPercentage = (value: number, min: number, max: number): number => {
+ if (max <= min) {
+ return 0;
+ }
+
+ return Math.round(((value - min) / (max - min)) * 100);
+};
+
+export const getProgressStatus = (percentage: number, tone: ProgressTone): string => {
+ if (percentage === 100) {
+ return 'Complete';
+ }
+
+ if (tone === 'danger') {
+ return 'Needs attention';
+ }
+
+ if (tone === 'warning') {
+ return 'In review';
+ }
+
+ return 'On track';
+};