From ca1af4853f60963a4f515d6819e0fd38bf1cb6d1 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 11 May 2026 15:27:06 +1000 Subject: [PATCH 1/5] [number field] Respect rounding mode on blur --- .../input/NumberFieldInput.test.tsx | 85 +++++++++++++++++++ .../number-field/input/NumberFieldInput.tsx | 3 +- .../src/number-field/utils/validate.test.ts | 46 ++++++++++ .../react/src/number-field/utils/validate.ts | 40 +++++++-- 4 files changed, 167 insertions(+), 7 deletions(-) diff --git a/packages/react/src/number-field/input/NumberFieldInput.test.tsx b/packages/react/src/number-field/input/NumberFieldInput.test.tsx index 6b057dfa32f..262f3f39bcd 100644 --- a/packages/react/src/number-field/input/NumberFieldInput.test.tsx +++ b/packages/react/src/number-field/input/NumberFieldInput.test.tsx @@ -585,6 +585,61 @@ describe('', () => { expect(onValueChange.mock.calls[0][0]).toBe(1.23); }); + async function renderControlledNumberField( + format: Intl.NumberFormatOptions, + locale: Intl.LocalesArgument = 'en-US', + ) { + const onValueChange = vi.fn(); + + function Controlled() { + const [value, setValue] = React.useState(null); + return ( + { + onValueChange(nextValue); + setValue(nextValue); + }} + format={format} + locale={locale} + > + + + ); + } + + const { user } = await render(); + const input = screen.getByRole('textbox'); + + await act(async () => { + input.focus(); + }); + + return { input, onValueChange, user }; + } + + it.each([ + ['en-US', '1.239'], + ['fr-FR', '1,239'], + ['ar-EG', '١٫٢٣٩'], + ] as const)( + 'should respect roundingMode when rounding to explicit maximumFractionDigits on blur in %s', + async (locale, inputText) => { + const format = { + maximumFractionDigits: 2, + roundingMode: 'floor', + }; + + const { input, onValueChange, user } = await renderControlledNumberField(format, locale); + + await user.keyboard(inputText); + fireEvent.blur(input); + + expect(onValueChange.mock.lastCall?.[0]).toBe(1.23); + expect(input).toHaveValue(new Intl.NumberFormat(locale, format).format(1.239)); + }, + ); + it('should not throw on blur when format uses roundingIncrement with fixed fraction digits', async () => { const format = { minimumFractionDigits: 1, @@ -611,6 +666,36 @@ describe('', () => { expect(input).toHaveValue(expectedValue); }); + it.each([ + [ + 'percent', + { + style: 'percent', + maximumFractionDigits: 2, + roundingMode: 'floor', + }, + 0.0123, + ], + [ + 'unit percent', + { + style: 'unit', + unit: 'percent', + maximumFractionDigits: 2, + roundingMode: 'floor', + }, + 1.23, + ], + ] as const)('should round %s values on blur', async (_, format, expectedValue) => { + const { input, onValueChange, user } = await renderControlledNumberField(format); + + await user.keyboard('1.239%'); + fireEvent.blur(input); + + expect(onValueChange.mock.lastCall?.[0]).toBe(expectedValue); + expect(input).toHaveValue('1.23%'); + }); + it('should round to step precision on blur when step implies precision constraints', async () => { const onValueChange = vi.fn(); diff --git a/packages/react/src/number-field/input/NumberFieldInput.tsx b/packages/react/src/number-field/input/NumberFieldInput.tsx index 5ef0f3e8903..a17104c51b6 100644 --- a/packages/react/src/number-field/input/NumberFieldInput.tsx +++ b/packages/react/src/number-field/input/NumberFieldInput.tsx @@ -31,6 +31,7 @@ import { import { formatNumber, formatNumberMaxPrecision } from '../../utils/formatNumber'; import { useValueChanged } from '../../internals/useValueChanged'; import { REASONS } from '../../internals/reasons'; +import { roundToFractionDigits } from '../utils/validate'; const stateAttributesMapping = { ...fieldValidityMapping, @@ -189,7 +190,7 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput( const maxFrac = formatOptions?.maximumFractionDigits; const committed = hasExplicitPrecision && typeof maxFrac === 'number' - ? Number(parsedValue.toFixed(maxFrac)) + ? roundToFractionDigits(parsedValue, maxFrac, formatOptions) : parsedValue; const nextEventDetails = createGenericEventDetails(REASONS.inputBlur, event.nativeEvent); diff --git a/packages/react/src/number-field/utils/validate.test.ts b/packages/react/src/number-field/utils/validate.test.ts index da180e99dcb..8381478f6b4 100644 --- a/packages/react/src/number-field/utils/validate.test.ts +++ b/packages/react/src/number-field/utils/validate.test.ts @@ -25,6 +25,52 @@ describe('NumberField validate', () => { expect(removeFloatingPointErrors(0.2 + 0.1, { maximumFractionDigits: 1 })).toBe(0.3); }); + it('respects roundingMode when maximumFractionDigits is provided', () => { + expect( + removeFloatingPointErrors(1.239, { + maximumFractionDigits: 2, + roundingMode: 'floor', + }), + ).toBe(1.23); + }); + + it('rounds percent values at display scale when maximumFractionDigits is provided', () => { + expect( + removeFloatingPointErrors(0.01236, { + style: 'percent', + maximumFractionDigits: 2, + }), + ).toBe(0.0124); + expect( + removeFloatingPointErrors(0.01239, { + style: 'percent', + maximumFractionDigits: 2, + roundingMode: 'floor', + }), + ).toBe(0.0123); + }); + + it('does not scale unit percent values when maximumFractionDigits is provided', () => { + expect( + removeFloatingPointErrors(1.239, { + style: 'unit', + unit: 'percent', + maximumFractionDigits: 2, + roundingMode: 'floor', + }), + ).toBe(1.23); + }); + + it('respects roundingIncrement when maximumFractionDigits is provided', () => { + expect( + removeFloatingPointErrors(1.26, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + roundingIncrement: 5, + }), + ).toBe(1.5); + }); + it('returns 1000 for 1000, ignoring grouping', () => { expect(removeFloatingPointErrors(1000)).toBe(1000); }); diff --git a/packages/react/src/number-field/utils/validate.ts b/packages/react/src/number-field/utils/validate.ts index b1096b73011..40fd1cd962e 100644 --- a/packages/react/src/number-field/utils/validate.ts +++ b/packages/react/src/number-field/utils/validate.ts @@ -3,7 +3,13 @@ import { getFormatter } from '../../utils/formatNumber'; const STEP_EPSILON_FACTOR = 1e-10; -function getFractionDigits(format?: Intl.NumberFormatOptions) { +// The repo's configured Intl types do not include the newer NumberFormat v3 rounding options yet. +type NumberFormatOptionsWithRounding = Intl.NumberFormatOptions & { + roundingIncrement?: number | undefined; + roundingMode?: string | undefined; +}; + +function getFractionDigits(format?: NumberFormatOptionsWithRounding) { const defaultOptions = getFormatter('en-US').resolvedOptions(); const minimumFractionDigits = format?.minimumFractionDigits ?? defaultOptions.minimumFractionDigits ?? 0; @@ -14,17 +20,39 @@ function getFractionDigits(format?: Intl.NumberFormatOptions) { return { maximumFractionDigits, minimumFractionDigits }; } -function roundToFractionDigits(value: number, maximumFractionDigits: number) { +export function roundToFractionDigits( + value: number, + maximumFractionDigits: number, + format?: NumberFormatOptionsWithRounding, +) { if (!Number.isFinite(value)) { return value; } const digits = Math.min(Math.max(maximumFractionDigits, 0), 20); - return Number(value.toFixed(digits)); + const isPercentWithExplicitPrecision = + format?.style === 'percent' && + (format.maximumFractionDigits != null || format.minimumFractionDigits != null); + const scale = isPercentWithExplicitPrecision ? 100 : 1; + const valueToRound = value * scale; + + if (format?.roundingIncrement == null && format?.roundingMode == null) { + return Number(valueToRound.toFixed(digits)) / scale; + } + + const roundingFormatOptions: NumberFormatOptionsWithRounding = { + useGrouping: false, + minimumFractionDigits: digits, + maximumFractionDigits: digits, + roundingIncrement: format?.roundingIncrement, + roundingMode: format?.roundingMode, + }; + + return Number(getFormatter('en-US', roundingFormatOptions).format(valueToRound)) / scale; } -export function removeFloatingPointErrors(value: number, format?: Intl.NumberFormatOptions) { +export function removeFloatingPointErrors(value: number, format?: NumberFormatOptionsWithRounding) { const { maximumFractionDigits } = getFractionDigits(format); - return roundToFractionDigits(value, maximumFractionDigits); + return roundToFractionDigits(value, maximumFractionDigits, format); } function snapToStep( @@ -73,7 +101,7 @@ export function toValidatedNumber( minWithDefault: number; maxWithDefault: number; minWithZeroDefault: number; - format: Intl.NumberFormatOptions | undefined; + format: NumberFormatOptionsWithRounding | undefined; snapOnStep: boolean; small: boolean; clamp: boolean; From 8819f1515358cb70654b30be93179d9c4df82b81 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 12 May 2026 16:20:59 +1000 Subject: [PATCH 2/5] [number field] Fix min-only percent rounding --- .../input/NumberFieldInput.test.tsx | 9 ++++++++ .../number-field/input/NumberFieldInput.tsx | 8 +++---- .../src/number-field/utils/validate.test.ts | 10 +++++++++ .../react/src/number-field/utils/validate.ts | 21 ++++++++++--------- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/react/src/number-field/input/NumberFieldInput.test.tsx b/packages/react/src/number-field/input/NumberFieldInput.test.tsx index 262f3f39bcd..92ca949f261 100644 --- a/packages/react/src/number-field/input/NumberFieldInput.test.tsx +++ b/packages/react/src/number-field/input/NumberFieldInput.test.tsx @@ -676,6 +676,15 @@ describe('', () => { }, 0.0123, ], + [ + 'percent with min-only precision', + { + style: 'percent', + minimumFractionDigits: 2, + roundingMode: 'floor', + }, + 0.0123, + ], [ 'unit percent', { diff --git a/packages/react/src/number-field/input/NumberFieldInput.tsx b/packages/react/src/number-field/input/NumberFieldInput.tsx index a17104c51b6..97bf0570e5f 100644 --- a/packages/react/src/number-field/input/NumberFieldInput.tsx +++ b/packages/react/src/number-field/input/NumberFieldInput.tsx @@ -187,11 +187,9 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput( formatOptions?.maximumFractionDigits != null || formatOptions?.minimumFractionDigits != null; - const maxFrac = formatOptions?.maximumFractionDigits; - const committed = - hasExplicitPrecision && typeof maxFrac === 'number' - ? roundToFractionDigits(parsedValue, maxFrac, formatOptions) - : parsedValue; + const committed = hasExplicitPrecision + ? roundToFractionDigits(parsedValue, formatOptions) + : parsedValue; const nextEventDetails = createGenericEventDetails(REASONS.inputBlur, event.nativeEvent); const shouldUpdateValue = value !== committed; diff --git a/packages/react/src/number-field/utils/validate.test.ts b/packages/react/src/number-field/utils/validate.test.ts index 8381478f6b4..62b86e3b31a 100644 --- a/packages/react/src/number-field/utils/validate.test.ts +++ b/packages/react/src/number-field/utils/validate.test.ts @@ -50,6 +50,16 @@ describe('NumberField validate', () => { ).toBe(0.0123); }); + it('rounds percent values at display scale when only minimumFractionDigits is provided', () => { + expect( + removeFloatingPointErrors(0.01239, { + style: 'percent', + minimumFractionDigits: 2, + roundingMode: 'floor', + }), + ).toBe(0.0123); + }); + it('does not scale unit percent values when maximumFractionDigits is provided', () => { expect( removeFloatingPointErrors(1.239, { diff --git a/packages/react/src/number-field/utils/validate.ts b/packages/react/src/number-field/utils/validate.ts index 40fd1cd962e..17f1b88bcc7 100644 --- a/packages/react/src/number-field/utils/validate.ts +++ b/packages/react/src/number-field/utils/validate.ts @@ -10,24 +10,26 @@ type NumberFormatOptionsWithRounding = Intl.NumberFormatOptions & { }; function getFractionDigits(format?: NumberFormatOptionsWithRounding) { - const defaultOptions = getFormatter('en-US').resolvedOptions(); + const hasExplicitPrecision = + format?.maximumFractionDigits != null || format?.minimumFractionDigits != null; + const resolvedOptions = getFormatter( + 'en-US', + hasExplicitPrecision ? format : undefined, + ).resolvedOptions(); const minimumFractionDigits = - format?.minimumFractionDigits ?? defaultOptions.minimumFractionDigits ?? 0; + format?.minimumFractionDigits ?? resolvedOptions.minimumFractionDigits ?? 0; const maximumFractionDigits = Math.max( - format?.maximumFractionDigits ?? defaultOptions.maximumFractionDigits ?? 20, + format?.maximumFractionDigits ?? resolvedOptions.maximumFractionDigits ?? 20, minimumFractionDigits, ); return { maximumFractionDigits, minimumFractionDigits }; } -export function roundToFractionDigits( - value: number, - maximumFractionDigits: number, - format?: NumberFormatOptionsWithRounding, -) { +export function roundToFractionDigits(value: number, format?: NumberFormatOptionsWithRounding) { if (!Number.isFinite(value)) { return value; } + const { maximumFractionDigits } = getFractionDigits(format); const digits = Math.min(Math.max(maximumFractionDigits, 0), 20); const isPercentWithExplicitPrecision = format?.style === 'percent' && @@ -51,8 +53,7 @@ export function roundToFractionDigits( } export function removeFloatingPointErrors(value: number, format?: NumberFormatOptionsWithRounding) { - const { maximumFractionDigits } = getFractionDigits(format); - return roundToFractionDigits(value, maximumFractionDigits, format); + return roundToFractionDigits(value, format); } function snapToStep( From 0fd621150e69560c41e006cc753fc418d3542f23 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 12 May 2026 16:37:33 +1000 Subject: [PATCH 3/5] [number field] Simplify rounding validation --- .../number-field/input/NumberFieldInput.tsx | 4 +-- .../react/src/number-field/utils/validate.ts | 36 ++++++++----------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/react/src/number-field/input/NumberFieldInput.tsx b/packages/react/src/number-field/input/NumberFieldInput.tsx index 97bf0570e5f..1738281fab7 100644 --- a/packages/react/src/number-field/input/NumberFieldInput.tsx +++ b/packages/react/src/number-field/input/NumberFieldInput.tsx @@ -31,7 +31,7 @@ import { import { formatNumber, formatNumberMaxPrecision } from '../../utils/formatNumber'; import { useValueChanged } from '../../internals/useValueChanged'; import { REASONS } from '../../internals/reasons'; -import { roundToFractionDigits } from '../utils/validate'; +import { removeFloatingPointErrors } from '../utils/validate'; const stateAttributesMapping = { ...fieldValidityMapping, @@ -188,7 +188,7 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput( formatOptions?.minimumFractionDigits != null; const committed = hasExplicitPrecision - ? roundToFractionDigits(parsedValue, formatOptions) + ? removeFloatingPointErrors(parsedValue, formatOptions) : parsedValue; const nextEventDetails = createGenericEventDetails(REASONS.inputBlur, event.nativeEvent); diff --git a/packages/react/src/number-field/utils/validate.ts b/packages/react/src/number-field/utils/validate.ts index 17f1b88bcc7..d3c87ea84d4 100644 --- a/packages/react/src/number-field/utils/validate.ts +++ b/packages/react/src/number-field/utils/validate.ts @@ -9,28 +9,24 @@ type NumberFormatOptionsWithRounding = Intl.NumberFormatOptions & { roundingMode?: string | undefined; }; -function getFractionDigits(format?: NumberFormatOptionsWithRounding) { - const hasExplicitPrecision = - format?.maximumFractionDigits != null || format?.minimumFractionDigits != null; - const resolvedOptions = getFormatter( - 'en-US', - hasExplicitPrecision ? format : undefined, - ).resolvedOptions(); - const minimumFractionDigits = - format?.minimumFractionDigits ?? resolvedOptions.minimumFractionDigits ?? 0; - const maximumFractionDigits = Math.max( - format?.maximumFractionDigits ?? resolvedOptions.maximumFractionDigits ?? 20, - minimumFractionDigits, +function getMaximumFractionDigits(format?: NumberFormatOptionsWithRounding) { + return Math.max( + format?.maximumFractionDigits ?? + getFormatter( + 'en-US', + format?.minimumFractionDigits == null ? undefined : format, + ).resolvedOptions().maximumFractionDigits ?? + 20, + format?.minimumFractionDigits ?? 0, ); - return { maximumFractionDigits, minimumFractionDigits }; } -export function roundToFractionDigits(value: number, format?: NumberFormatOptionsWithRounding) { +export function removeFloatingPointErrors(value: number, format?: NumberFormatOptionsWithRounding) { if (!Number.isFinite(value)) { return value; } - const { maximumFractionDigits } = getFractionDigits(format); - const digits = Math.min(Math.max(maximumFractionDigits, 0), 20); + + const digits = Math.min(Math.max(getMaximumFractionDigits(format), 0), 20); const isPercentWithExplicitPrecision = format?.style === 'percent' && (format.maximumFractionDigits != null || format.minimumFractionDigits != null); @@ -45,17 +41,13 @@ export function roundToFractionDigits(value: number, format?: NumberFormatOption useGrouping: false, minimumFractionDigits: digits, maximumFractionDigits: digits, - roundingIncrement: format?.roundingIncrement, - roundingMode: format?.roundingMode, + roundingIncrement: format.roundingIncrement, + roundingMode: format.roundingMode, }; return Number(getFormatter('en-US', roundingFormatOptions).format(valueToRound)) / scale; } -export function removeFloatingPointErrors(value: number, format?: NumberFormatOptionsWithRounding) { - return roundToFractionDigits(value, format); -} - function snapToStep( clampedValue: number, base: number, From db7a2306585517685e680a81c6794394d1adcb46 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 13 May 2026 18:45:52 +1000 Subject: [PATCH 4/5] [number field] Harden rounding edge cases --- .../input/NumberFieldInput.test.tsx | 33 ++++++++++++ .../src/number-field/utils/validate.test.ts | 51 +++++++++++++++++++ .../react/src/number-field/utils/validate.ts | 6 ++- 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/react/src/number-field/input/NumberFieldInput.test.tsx b/packages/react/src/number-field/input/NumberFieldInput.test.tsx index 92ca949f261..d5555b9e037 100644 --- a/packages/react/src/number-field/input/NumberFieldInput.test.tsx +++ b/packages/react/src/number-field/input/NumberFieldInput.test.tsx @@ -666,6 +666,22 @@ describe('', () => { expect(input).toHaveValue(expectedValue); }); + it('should commit roundingIncrement values on blur', async () => { + const format = { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + roundingIncrement: 5, + }; + + const { input, onValueChange, user } = await renderControlledNumberField(format); + + await user.keyboard('1.26'); + fireEvent.blur(input); + + expect(onValueChange.mock.lastCall?.[0]).toBe(1.5); + expect(input).toHaveValue(new Intl.NumberFormat('en-US', format).format(1.26)); + }); + it.each([ [ 'percent', @@ -705,6 +721,23 @@ describe('', () => { expect(input).toHaveValue('1.23%'); }); + it('should round currency values on blur without percent scaling', async () => { + const format = { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, + roundingMode: 'floor', + } as const; + + const { input, onValueChange, user } = await renderControlledNumberField(format); + + await user.keyboard('1.239'); + fireEvent.blur(input); + + expect(onValueChange.mock.lastCall?.[0]).toBe(1.23); + expect(input).toHaveValue(new Intl.NumberFormat('en-US', format).format(1.239)); + }); + it('should round to step precision on blur when step implies precision constraints', async () => { const onValueChange = vi.fn(); diff --git a/packages/react/src/number-field/utils/validate.test.ts b/packages/react/src/number-field/utils/validate.test.ts index 62b86e3b31a..4ac326dbcc4 100644 --- a/packages/react/src/number-field/utils/validate.test.ts +++ b/packages/react/src/number-field/utils/validate.test.ts @@ -34,6 +34,36 @@ describe('NumberField validate', () => { ).toBe(1.23); }); + it('respects half-even rounding at ties', () => { + expect( + removeFloatingPointErrors(1.235, { + maximumFractionDigits: 2, + roundingMode: 'halfEven', + }), + ).toBe(1.24); + expect( + removeFloatingPointErrors(1.245, { + maximumFractionDigits: 2, + roundingMode: 'halfEven', + }), + ).toBe(1.24); + }); + + it('respects rounding mode differences for negative values', () => { + expect( + removeFloatingPointErrors(-1.239, { + maximumFractionDigits: 2, + roundingMode: 'floor', + }), + ).toBe(-1.24); + expect( + removeFloatingPointErrors(-1.239, { + maximumFractionDigits: 2, + roundingMode: 'trunc', + }), + ).toBe(-1.23); + }); + it('rounds percent values at display scale when maximumFractionDigits is provided', () => { expect( removeFloatingPointErrors(0.01236, { @@ -71,6 +101,17 @@ describe('NumberField validate', () => { ).toBe(1.23); }); + it('rounds currency values without percent scaling', () => { + expect( + removeFloatingPointErrors(1.239, { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, + roundingMode: 'floor', + }), + ).toBe(1.23); + }); + it('respects roundingIncrement when maximumFractionDigits is provided', () => { expect( removeFloatingPointErrors(1.26, { @@ -81,6 +122,16 @@ describe('NumberField validate', () => { ).toBe(1.5); }); + it('does not return NaN when percent scaling overflows before Intl rounding', () => { + expect( + removeFloatingPointErrors(Number.MAX_VALUE, { + style: 'percent', + maximumFractionDigits: 2, + roundingMode: 'floor', + }), + ).toBe(Infinity); + }); + it('returns 1000 for 1000, ignoring grouping', () => { expect(removeFloatingPointErrors(1000)).toBe(1000); }); diff --git a/packages/react/src/number-field/utils/validate.ts b/packages/react/src/number-field/utils/validate.ts index d3c87ea84d4..689f13a85b1 100644 --- a/packages/react/src/number-field/utils/validate.ts +++ b/packages/react/src/number-field/utils/validate.ts @@ -3,7 +3,7 @@ import { getFormatter } from '../../utils/formatNumber'; const STEP_EPSILON_FACTOR = 1e-10; -// The repo's configured Intl types do not include the newer NumberFormat v3 rounding options yet. +// The repo compiles against es2022 Intl types, so model NumberFormat v3 options locally. type NumberFormatOptionsWithRounding = Intl.NumberFormatOptions & { roundingIncrement?: number | undefined; roundingMode?: string | undefined; @@ -33,6 +33,10 @@ export function removeFloatingPointErrors(value: number, format?: NumberFormatOp const scale = isPercentWithExplicitPrecision ? 100 : 1; const valueToRound = value * scale; + if (!Number.isFinite(valueToRound)) { + return valueToRound / scale; + } + if (format?.roundingIncrement == null && format?.roundingMode == null) { return Number(valueToRound.toFixed(digits)) / scale; } From ab7611d66353ce188ab0f78c7c416e53960e4bc3 Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 14 May 2026 15:49:16 +1000 Subject: [PATCH 5/5] [number field] Avoid non-finite blur commits --- .../input/NumberFieldInput.test.tsx | 33 +++++++++++++++++++ .../src/number-field/utils/parse.test.ts | 9 +++++ .../react/src/number-field/utils/parse.ts | 2 +- .../src/number-field/utils/validate.test.ts | 18 ++++++++-- .../react/src/number-field/utils/validate.ts | 5 ++- 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/react/src/number-field/input/NumberFieldInput.test.tsx b/packages/react/src/number-field/input/NumberFieldInput.test.tsx index d5555b9e037..5d478f9a158 100644 --- a/packages/react/src/number-field/input/NumberFieldInput.test.tsx +++ b/packages/react/src/number-field/input/NumberFieldInput.test.tsx @@ -738,6 +738,39 @@ describe('', () => { expect(input).toHaveValue(new Intl.NumberFormat('en-US', format).format(1.239)); }); + it('should not commit values that overflow while parsing on blur', async () => { + const format = { + style: 'percent', + maximumFractionDigits: 2, + roundingMode: 'floor', + } as const; + const onValueChange = vi.fn(); + const onValueCommitted = vi.fn(); + + await render( + + + , + ); + + const input = screen.getByRole('textbox'); + const formattedOverflow = new Intl.NumberFormat('en-US', format).format(Number.MAX_VALUE); + + await act(async () => { + input.focus(); + }); + fireEvent.change(input, { target: { value: formattedOverflow } }); + fireEvent.blur(input); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(onValueCommitted).not.toHaveBeenCalled(); + expect(input).toHaveValue(formattedOverflow); + }); + it('should round to step precision on blur when step implies precision constraints', async () => { const onValueChange = vi.fn(); diff --git a/packages/react/src/number-field/utils/parse.test.ts b/packages/react/src/number-field/utils/parse.test.ts index 2721c805ee0..22b0f011c7f 100644 --- a/packages/react/src/number-field/utils/parse.test.ts +++ b/packages/react/src/number-field/utils/parse.test.ts @@ -108,6 +108,15 @@ describe('NumberField parse', () => { expect(parseNumber('∞')).toBe(null); }); + it('returns null when parsing overflows to Infinity', () => { + const formatted = new Intl.NumberFormat('en-US', { + style: 'percent', + maximumFractionDigits: 2, + }).format(Number.MAX_VALUE); + + expect(parseNumber(formatted, 'en-US', { style: 'percent' })).toBe(null); + }); + it('collapses extra dots from mixed-locale inputs', () => { // Last '.' is decimal; previous '.' are removed expect(parseNumber('1.234.567.89')).toBe(1234567.89); diff --git a/packages/react/src/number-field/utils/parse.ts b/packages/react/src/number-field/utils/parse.ts index c95bc237172..ee81a76868f 100644 --- a/packages/react/src/number-field/utils/parse.ts +++ b/packages/react/src/number-field/utils/parse.ts @@ -214,7 +214,7 @@ export function parseNumber( num /= 100; } - if (Number.isNaN(num)) { + if (!Number.isFinite(num)) { return null; } diff --git a/packages/react/src/number-field/utils/validate.test.ts b/packages/react/src/number-field/utils/validate.test.ts index 4ac326dbcc4..c1bb00295f3 100644 --- a/packages/react/src/number-field/utils/validate.test.ts +++ b/packages/react/src/number-field/utils/validate.test.ts @@ -122,14 +122,14 @@ describe('NumberField validate', () => { ).toBe(1.5); }); - it('does not return NaN when percent scaling overflows before Intl rounding', () => { + it('keeps the original finite value when percent scaling overflows before Intl rounding', () => { expect( removeFloatingPointErrors(Number.MAX_VALUE, { style: 'percent', maximumFractionDigits: 2, roundingMode: 'floor', }), - ).toBe(Infinity); + ).toBe(Number.MAX_VALUE); }); it('returns 1000 for 1000, ignoring grouping', () => { @@ -281,6 +281,20 @@ describe('NumberField validate', () => { }); }); + it('applies roundingMode after step validation', () => { + expect( + toValidatedNumber(1.239, { + ...defaultOptions, + step: 0.001, + snapOnStep: true, + format: { + maximumFractionDigits: 2, + roundingMode: 'floor', + }, + }), + ).toBe(1.23); + }); + it('removes floating point errors by default', () => { expect( toValidatedNumber(0.2 + 0.1, { diff --git a/packages/react/src/number-field/utils/validate.ts b/packages/react/src/number-field/utils/validate.ts index 689f13a85b1..3935ce55446 100644 --- a/packages/react/src/number-field/utils/validate.ts +++ b/packages/react/src/number-field/utils/validate.ts @@ -4,12 +4,14 @@ import { getFormatter } from '../../utils/formatNumber'; const STEP_EPSILON_FACTOR = 1e-10; // The repo compiles against es2022 Intl types, so model NumberFormat v3 options locally. +// Delete this once tsconfig.base.json includes es2023. type NumberFormatOptionsWithRounding = Intl.NumberFormatOptions & { roundingIncrement?: number | undefined; roundingMode?: string | undefined; }; function getMaximumFractionDigits(format?: NumberFormatOptionsWithRounding) { + // Preserve the old decimal defaults unless min-only precision needs style-specific defaults. return Math.max( format?.maximumFractionDigits ?? getFormatter( @@ -27,6 +29,7 @@ export function removeFloatingPointErrors(value: number, format?: NumberFormatOp } const digits = Math.min(Math.max(getMaximumFractionDigits(format), 0), 20); + // Percent values are stored as fractions, so rounding must happen at the displayed scale. const isPercentWithExplicitPrecision = format?.style === 'percent' && (format.maximumFractionDigits != null || format.minimumFractionDigits != null); @@ -34,7 +37,7 @@ export function removeFloatingPointErrors(value: number, format?: NumberFormatOp const valueToRound = value * scale; if (!Number.isFinite(valueToRound)) { - return valueToRound / scale; + return value; } if (format?.roundingIncrement == null && format?.roundingMode == null) {