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;