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 && ( + + )} + + {showMarkers && !indeterminate && ( + + )} + + + ); +} + +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'; +};