From c7f6d0438fc5f31354e3cf2dd65f32991eec7c12 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:42:21 -0700 Subject: [PATCH 1/3] fix: guard ProgressBar/Meter percentage against NaN when min equals max --- packages/react-aria-components/src/Meter.tsx | 14 +++- .../react-aria-components/src/ProgressBar.tsx | 15 +++- .../react-aria-components/test/Meter.test.js | 40 ++++++++++ .../test/ProgressBar.test.js | 73 +++++++++++++++++-- 4 files changed, 132 insertions(+), 10 deletions(-) diff --git a/packages/react-aria-components/src/Meter.tsx b/packages/react-aria-components/src/Meter.tsx index e3f27032dec..9b204ce1c0f 100644 --- a/packages/react-aria-components/src/Meter.tsx +++ b/packages/react-aria-components/src/Meter.tsx @@ -11,6 +11,7 @@ */ import {AriaMeterProps, useMeter} from 'react-aria/useMeter'; +import {useNumberFormatter} from 'react-aria/useNumberFormatter'; import {clamp} from 'react-stately/private/utils/number'; import { @@ -59,6 +60,8 @@ export interface MeterRenderProps { export const MeterContext = createContext>(null); +const DEFAULT_FORMAT_OPTIONS: Intl.NumberFormatOptions = {style: 'percent'}; + /** * A meter represents a quantity within a known range, or a fractional value. */ @@ -69,12 +72,19 @@ export const Meter = /*#__PURE__*/ (forwardRef as forwardRefType)(function Meter [props, ref] = useContextProps(props, ref, MeterContext); let {value = 0, minValue = 0, maxValue = 100} = props; value = clamp(value, minValue, maxValue); + let range = maxValue - minValue; + let formatOptions = props.formatOptions ?? DEFAULT_FORMAT_OPTIONS; + let formatter = useNumberFormatter(formatOptions); let [labelRef, label] = useSlot(!props['aria-label'] && !props['aria-labelledby']); - let {meterProps, labelProps} = useMeter({...props, label}); + let valueLabel = + range === 0 && !props.valueLabel && formatOptions.style === 'percent' + ? formatter.format(0) + : props.valueLabel; + let {meterProps, labelProps} = useMeter({...props, label, valueLabel}); // Calculate the width of the progress bar as a percentage - let percentage = ((value - minValue) / (maxValue - minValue)) * 100; + let percentage = range === 0 ? 0 : ((value - minValue) / range) * 100; let renderProps = useRenderProps({ ...props, diff --git a/packages/react-aria-components/src/ProgressBar.tsx b/packages/react-aria-components/src/ProgressBar.tsx index 3d546f92174..979f5f6caf5 100644 --- a/packages/react-aria-components/src/ProgressBar.tsx +++ b/packages/react-aria-components/src/ProgressBar.tsx @@ -11,6 +11,7 @@ */ import {AriaProgressBarProps, useProgressBar} from 'react-aria/useProgressBar'; +import {useNumberFormatter} from 'react-aria/useNumberFormatter'; import {clamp} from 'react-stately/private/utils/number'; import { @@ -66,6 +67,8 @@ export interface ProgressBarRenderProps { export const ProgressBarContext = createContext>(null); +const DEFAULT_FORMAT_OPTIONS: Intl.NumberFormatOptions = {style: 'percent'}; + /** * Progress bars show either determinate or indeterminate progress of an operation * over time. @@ -77,12 +80,20 @@ export const ProgressBar = forwardRef(function ProgressBar( [props, ref] = useContextProps(props, ref, ProgressBarContext); let {value = 0, minValue = 0, maxValue = 100, isIndeterminate = false} = props; value = clamp(value, minValue, maxValue); + let range = maxValue - minValue; + let formatOptions = props.formatOptions ?? DEFAULT_FORMAT_OPTIONS; + let formatter = useNumberFormatter(formatOptions); let [labelRef, label] = useSlot(!props['aria-label'] && !props['aria-labelledby']); - let {progressBarProps, labelProps} = useProgressBar({...props, label}); + let valueLabel = + !isIndeterminate && range === 0 && !props.valueLabel && formatOptions.style === 'percent' + ? formatter.format(0) + : props.valueLabel; + let {progressBarProps, labelProps} = useProgressBar({...props, label, valueLabel}); // Calculate the width of the progress bar as a percentage - let percentage = isIndeterminate ? undefined : ((value - minValue) / (maxValue - minValue)) * 100; + let percentage = + isIndeterminate ? undefined : range === 0 ? 0 : ((value - minValue) / range) * 100; let renderProps = useRenderProps({ ...props, diff --git a/packages/react-aria-components/test/Meter.test.js b/packages/react-aria-components/test/Meter.test.js index aa11b3f80d0..1053af69ae8 100644 --- a/packages/react-aria-components/test/Meter.test.js +++ b/packages/react-aria-components/test/Meter.test.js @@ -22,6 +22,7 @@ let TestMeter = props => ( <> {valueText} + {percentage}
)} @@ -48,6 +49,45 @@ describe('Meter', () => { expect(bar).toHaveStyle('width: 25%'); }); + it('supports a custom range', () => { + let {getByRole} = render(); + + let meter = getByRole('meter'); + expect(meter).toHaveAttribute('aria-valuenow', '3'); + expect(meter).toHaveAttribute('aria-valuemin', '0'); + expect(meter).toHaveAttribute('aria-valuemax', '6'); + expect(meter).toHaveAttribute('aria-valuetext', '50%'); + + let value = meter.querySelector('.value'); + expect(value).toHaveTextContent('50%'); + + let percentage = meter.querySelector('.percentage'); + expect(percentage).toHaveTextContent('50'); + + let bar = meter.querySelector('.bar'); + expect(bar).toHaveStyle('width: 50%'); + }); + + it('renders 0 percent for an empty range', () => { + let {getByRole} = render(); + + let meter = getByRole('meter'); + expect(meter).toHaveAttribute('aria-valuenow', '0'); + expect(meter).toHaveAttribute('aria-valuemin', '0'); + expect(meter).toHaveAttribute('aria-valuemax', '0'); + expect(meter).toHaveAttribute('aria-valuetext', '0%'); + expect(meter).not.toHaveAttribute('aria-valuetext', 'NaN%'); + + let value = meter.querySelector('.value'); + expect(value).toHaveTextContent('0%'); + + let percentage = meter.querySelector('.percentage'); + expect(percentage).toHaveTextContent('0'); + + let bar = meter.querySelector('.bar'); + expect(bar).toHaveStyle('width: 0%'); + }); + it('should support slot', () => { let {getByRole} = render( diff --git a/packages/react-aria-components/test/ProgressBar.test.js b/packages/react-aria-components/test/ProgressBar.test.js index bbee1b2f8ab..15924de3553 100644 --- a/packages/react-aria-components/test/ProgressBar.test.js +++ b/packages/react-aria-components/test/ProgressBar.test.js @@ -22,6 +22,7 @@ let TestProgressBar = props => ( <> {valueText} + {percentage}
)} @@ -48,23 +49,83 @@ describe('ProgressBar', () => { expect(bar).toHaveStyle('width: 25%'); }); + it('supports a custom range', () => { + let {getByRole} = render(); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toHaveAttribute('aria-valuenow', '3'); + expect(progressbar).toHaveAttribute('aria-valuemin', '0'); + expect(progressbar).toHaveAttribute('aria-valuemax', '6'); + expect(progressbar).toHaveAttribute('aria-valuetext', '50%'); + + let value = progressbar.querySelector('.value'); + expect(value).toHaveTextContent('50%'); + + let percentage = progressbar.querySelector('.percentage'); + expect(percentage).toHaveTextContent('50'); + + let bar = progressbar.querySelector('.bar'); + expect(bar).toHaveStyle('width: 50%'); + }); + + it('renders 0 percent for an empty range', () => { + let {getByRole} = render(); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toHaveAttribute('aria-valuenow', '0'); + expect(progressbar).toHaveAttribute('aria-valuemin', '0'); + expect(progressbar).toHaveAttribute('aria-valuemax', '0'); + expect(progressbar).toHaveAttribute('aria-valuetext', '0%'); + expect(progressbar).not.toHaveAttribute('aria-valuetext', 'NaN%'); + + let value = progressbar.querySelector('.value'); + expect(value).toHaveTextContent('0%'); + + let percentage = progressbar.querySelector('.percentage'); + expect(percentage).toHaveTextContent('0'); + + let bar = progressbar.querySelector('.bar'); + expect(bar).toHaveStyle('width: 0%'); + }); + + it('renders 0 percent for an empty range with a non-zero bound', () => { + let {getByRole} = render(); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toHaveAttribute('aria-valuenow', '5'); + expect(progressbar).toHaveAttribute('aria-valuemin', '5'); + expect(progressbar).toHaveAttribute('aria-valuemax', '5'); + expect(progressbar).toHaveAttribute('aria-valuetext', '0%'); + + let percentage = progressbar.querySelector('.percentage'); + expect(percentage).toHaveTextContent('0'); + + let bar = progressbar.querySelector('.bar'); + expect(bar).toHaveStyle('width: 0%'); + }); + it('supports indeterminate state', () => { + let renderedPercentage; let {getByRole} = render( `progressbar ${isIndeterminate ? 'indeterminate' : ''}`}> - {({percentage, valueText}) => ( - <> - -
- - )} + {({percentage}) => { + renderedPercentage = percentage; + return ( + <> + +
+ + ); + }} ); let progressbar = getByRole('progressbar'); expect(progressbar).toHaveAttribute('class', 'progressbar indeterminate'); expect(progressbar).not.toHaveAttribute('aria-valuenow'); + expect(renderedPercentage).toBeUndefined(); let bar = progressbar.querySelector('.bar'); expect(bar.style.width).toBe(''); From 7c40464ec61dd238bfc04a2b78a92a8d03b44e80 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 26 Jun 2026 10:06:27 +1000 Subject: [PATCH 2/3] simplify, fix hooks, and fix v3 --- .../src/progress/ProgressBarBase.tsx | 3 ++- .../src/progress/ProgressCircle.tsx | 3 ++- packages/react-aria-components/src/Meter.tsx | 10 +--------- .../react-aria-components/src/ProgressBar.tsx | 20 +++++++++---------- .../react-aria/src/progress/useProgressBar.ts | 3 ++- 5 files changed, 17 insertions(+), 22 deletions(-) diff --git a/packages/@adobe/react-spectrum/src/progress/ProgressBarBase.tsx b/packages/@adobe/react-spectrum/src/progress/ProgressBarBase.tsx index 6a5bd6f8a69..1059ac8a5fc 100644 --- a/packages/@adobe/react-spectrum/src/progress/ProgressBarBase.tsx +++ b/packages/@adobe/react-spectrum/src/progress/ProgressBarBase.tsx @@ -86,7 +86,8 @@ export const ProgressBarBase = React.forwardRef(function ProgressBarBase( let barStyle: CSSProperties = {}; if (!isIndeterminate) { - let percentage = (value - minValue) / (maxValue - minValue); + let range = maxValue - minValue; + let percentage = range === 0 ? 0 : (value - minValue) / range; barStyle.width = `${Math.round(percentage * 100)}%`; } diff --git a/packages/@adobe/react-spectrum/src/progress/ProgressCircle.tsx b/packages/@adobe/react-spectrum/src/progress/ProgressCircle.tsx index 9b0c82c1146..a78692cfcb1 100644 --- a/packages/@adobe/react-spectrum/src/progress/ProgressCircle.tsx +++ b/packages/@adobe/react-spectrum/src/progress/ProgressCircle.tsx @@ -92,7 +92,8 @@ export const ProgressCircle = React.forwardRef(function ProgressCircle( let subMask1Style: CSSProperties = {}; let subMask2Style: CSSProperties = {}; if (!isIndeterminate) { - let percentage = ((value - minValue) / (maxValue - minValue)) * 100; + let range = maxValue - minValue; + let percentage = range === 0 ? 0 : ((value - minValue) / range) * 100; let angle; if (percentage > 0 && percentage <= 50) { angle = -180 + (percentage / 50) * 180; diff --git a/packages/react-aria-components/src/Meter.tsx b/packages/react-aria-components/src/Meter.tsx index 9b204ce1c0f..d33f60d6690 100644 --- a/packages/react-aria-components/src/Meter.tsx +++ b/packages/react-aria-components/src/Meter.tsx @@ -60,8 +60,6 @@ export interface MeterRenderProps { export const MeterContext = createContext>(null); -const DEFAULT_FORMAT_OPTIONS: Intl.NumberFormatOptions = {style: 'percent'}; - /** * A meter represents a quantity within a known range, or a fractional value. */ @@ -73,15 +71,9 @@ export const Meter = /*#__PURE__*/ (forwardRef as forwardRefType)(function Meter let {value = 0, minValue = 0, maxValue = 100} = props; value = clamp(value, minValue, maxValue); let range = maxValue - minValue; - let formatOptions = props.formatOptions ?? DEFAULT_FORMAT_OPTIONS; - let formatter = useNumberFormatter(formatOptions); let [labelRef, label] = useSlot(!props['aria-label'] && !props['aria-labelledby']); - let valueLabel = - range === 0 && !props.valueLabel && formatOptions.style === 'percent' - ? formatter.format(0) - : props.valueLabel; - let {meterProps, labelProps} = useMeter({...props, label, valueLabel}); + let {meterProps, labelProps} = useMeter({...props, label}); // Calculate the width of the progress bar as a percentage let percentage = range === 0 ? 0 : ((value - minValue) / range) * 100; diff --git a/packages/react-aria-components/src/ProgressBar.tsx b/packages/react-aria-components/src/ProgressBar.tsx index 979f5f6caf5..28656a08676 100644 --- a/packages/react-aria-components/src/ProgressBar.tsx +++ b/packages/react-aria-components/src/ProgressBar.tsx @@ -80,20 +80,20 @@ export const ProgressBar = forwardRef(function ProgressBar( [props, ref] = useContextProps(props, ref, ProgressBarContext); let {value = 0, minValue = 0, maxValue = 100, isIndeterminate = false} = props; value = clamp(value, minValue, maxValue); - let range = maxValue - minValue; - let formatOptions = props.formatOptions ?? DEFAULT_FORMAT_OPTIONS; - let formatter = useNumberFormatter(formatOptions); let [labelRef, label] = useSlot(!props['aria-label'] && !props['aria-labelledby']); - let valueLabel = - !isIndeterminate && range === 0 && !props.valueLabel && formatOptions.style === 'percent' - ? formatter.format(0) - : props.valueLabel; - let {progressBarProps, labelProps} = useProgressBar({...props, label, valueLabel}); + let {progressBarProps, labelProps} = useProgressBar({...props, label}); + let range = maxValue - minValue; // Calculate the width of the progress bar as a percentage - let percentage = - isIndeterminate ? undefined : range === 0 ? 0 : ((value - minValue) / range) * 100; + let percentage = undefined; + if (!isIndeterminate) { + if (range === 0) { + percentage = 0; + } else { + percentage = ((value - minValue) / range) * 100; + } + } let renderProps = useRenderProps({ ...props, diff --git a/packages/react-aria/src/progress/useProgressBar.ts b/packages/react-aria/src/progress/useProgressBar.ts index 5b27625af9e..2ad3899b0f4 100644 --- a/packages/react-aria/src/progress/useProgressBar.ts +++ b/packages/react-aria/src/progress/useProgressBar.ts @@ -94,7 +94,8 @@ export function useProgressBar(props: AriaProgressBarProps): ProgressBarAria { }); value = clamp(value, minValue, maxValue); - let percentage = (value - minValue) / (maxValue - minValue); + let range = maxValue - minValue; + let percentage = range === 0 ? 0 : (value - minValue) / range; let formatter = useNumberFormatter(formatOptions); if (!isIndeterminate && !valueLabel) { From de0caed3d170d0f5127fbdc06e3e960381e0bf3f Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 26 Jun 2026 10:16:08 +1000 Subject: [PATCH 3/3] fix lint --- packages/react-aria-components/src/Meter.tsx | 2 -- packages/react-aria-components/src/ProgressBar.tsx | 6 +----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/react-aria-components/src/Meter.tsx b/packages/react-aria-components/src/Meter.tsx index d33f60d6690..d2060e40f95 100644 --- a/packages/react-aria-components/src/Meter.tsx +++ b/packages/react-aria-components/src/Meter.tsx @@ -11,8 +11,6 @@ */ import {AriaMeterProps, useMeter} from 'react-aria/useMeter'; -import {useNumberFormatter} from 'react-aria/useNumberFormatter'; - import {clamp} from 'react-stately/private/utils/number'; import { ClassNameOrFunction, diff --git a/packages/react-aria-components/src/ProgressBar.tsx b/packages/react-aria-components/src/ProgressBar.tsx index 28656a08676..c57ee4fbbc1 100644 --- a/packages/react-aria-components/src/ProgressBar.tsx +++ b/packages/react-aria-components/src/ProgressBar.tsx @@ -11,8 +11,6 @@ */ import {AriaProgressBarProps, useProgressBar} from 'react-aria/useProgressBar'; -import {useNumberFormatter} from 'react-aria/useNumberFormatter'; - import {clamp} from 'react-stately/private/utils/number'; import { ClassNameOrFunction, @@ -67,8 +65,6 @@ export interface ProgressBarRenderProps { export const ProgressBarContext = createContext>(null); -const DEFAULT_FORMAT_OPTIONS: Intl.NumberFormatOptions = {style: 'percent'}; - /** * Progress bars show either determinate or indeterminate progress of an operation * over time. @@ -86,7 +82,7 @@ export const ProgressBar = forwardRef(function ProgressBar( let range = maxValue - minValue; // Calculate the width of the progress bar as a percentage - let percentage = undefined; + let percentage: number | undefined = undefined; if (!isIndeterminate) { if (range === 0) { percentage = 0;