diff --git a/packages/react/src/number-field/input/NumberFieldInput.test.tsx b/packages/react/src/number-field/input/NumberFieldInput.test.tsx index 6b057dfa32f..5d478f9a158 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,111 @@ 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', + { + style: 'percent', + maximumFractionDigits: 2, + roundingMode: 'floor', + }, + 0.0123, + ], + [ + 'percent with min-only precision', + { + style: 'percent', + minimumFractionDigits: 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 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 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/input/NumberFieldInput.tsx b/packages/react/src/number-field/input/NumberFieldInput.tsx index 5ef0f3e8903..1738281fab7 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 { removeFloatingPointErrors } from '../utils/validate'; const stateAttributesMapping = { ...fieldValidityMapping, @@ -186,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' - ? Number(parsedValue.toFixed(maxFrac)) - : parsedValue; + const committed = hasExplicitPrecision + ? removeFloatingPointErrors(parsedValue, formatOptions) + : parsedValue; const nextEventDetails = createGenericEventDetails(REASONS.inputBlur, event.nativeEvent); const shouldUpdateValue = value !== committed; 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 da180e99dcb..c1bb00295f3 100644 --- a/packages/react/src/number-field/utils/validate.test.ts +++ b/packages/react/src/number-field/utils/validate.test.ts @@ -25,6 +25,113 @@ 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('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, { + style: 'percent', + maximumFractionDigits: 2, + }), + ).toBe(0.0124); + expect( + removeFloatingPointErrors(0.01239, { + style: 'percent', + maximumFractionDigits: 2, + roundingMode: 'floor', + }), + ).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, { + style: 'unit', + unit: 'percent', + maximumFractionDigits: 2, + roundingMode: 'floor', + }), + ).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, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + roundingIncrement: 5, + }), + ).toBe(1.5); + }); + + 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(Number.MAX_VALUE); + }); + it('returns 1000 for 1000, ignoring grouping', () => { expect(removeFloatingPointErrors(1000)).toBe(1000); }); @@ -174,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 b1096b73011..3935ce55446 100644 --- a/packages/react/src/number-field/utils/validate.ts +++ b/packages/react/src/number-field/utils/validate.ts @@ -3,28 +3,56 @@ import { getFormatter } from '../../utils/formatNumber'; const STEP_EPSILON_FACTOR = 1e-10; -function getFractionDigits(format?: Intl.NumberFormatOptions) { - const defaultOptions = getFormatter('en-US').resolvedOptions(); - const minimumFractionDigits = - format?.minimumFractionDigits ?? defaultOptions.minimumFractionDigits ?? 0; - const maximumFractionDigits = Math.max( - format?.maximumFractionDigits ?? defaultOptions.maximumFractionDigits ?? 20, - minimumFractionDigits, +// 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( + 'en-US', + format?.minimumFractionDigits == null ? undefined : format, + ).resolvedOptions().maximumFractionDigits ?? + 20, + format?.minimumFractionDigits ?? 0, ); - return { maximumFractionDigits, minimumFractionDigits }; } -function roundToFractionDigits(value: number, maximumFractionDigits: number) { +export function removeFloatingPointErrors(value: number, format?: NumberFormatOptionsWithRounding) { if (!Number.isFinite(value)) { return value; } - const digits = Math.min(Math.max(maximumFractionDigits, 0), 20); - return Number(value.toFixed(digits)); -} -export function removeFloatingPointErrors(value: number, format?: Intl.NumberFormatOptions) { - const { maximumFractionDigits } = getFractionDigits(format); - return roundToFractionDigits(value, maximumFractionDigits); + 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); + const scale = isPercentWithExplicitPrecision ? 100 : 1; + const valueToRound = value * scale; + + if (!Number.isFinite(valueToRound)) { + return value; + } + + 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; } 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;