Skip to content

Commit bc5b17c

Browse files
committed
[number field] Respect rounding mode on blur
1 parent ac7ebaf commit bc5b17c

4 files changed

Lines changed: 122 additions & 7 deletions

File tree

packages/react/src/number-field/input/NumberFieldInput.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,52 @@ describe('<NumberField.Input />', () => {
585585
expect(onValueChange.mock.calls[0][0]).toBe(1.23);
586586
});
587587

588+
(
589+
[
590+
['en-US', '1.239'],
591+
['fr-FR', '1,239'],
592+
['ar-EG', '١٫٢٣٩'],
593+
] as const
594+
).forEach(([locale, inputText]) => {
595+
it(`should respect roundingMode when rounding to explicit maximumFractionDigits on blur in ${locale}`, async () => {
596+
const onValueChange = vi.fn();
597+
const format = {
598+
maximumFractionDigits: 2,
599+
roundingMode: 'floor',
600+
};
601+
602+
function Controlled() {
603+
const [value, setValue] = React.useState<number | null>(null);
604+
return (
605+
<NumberField.Root
606+
value={value}
607+
onValueChange={(nextValue) => {
608+
onValueChange(nextValue);
609+
setValue(nextValue);
610+
}}
611+
format={format}
612+
locale={locale}
613+
>
614+
<NumberField.Input />
615+
</NumberField.Root>
616+
);
617+
}
618+
619+
const { user } = await render(<Controlled />);
620+
const input = screen.getByRole('textbox');
621+
622+
await act(async () => {
623+
input.focus();
624+
});
625+
626+
await user.keyboard(inputText);
627+
fireEvent.blur(input);
628+
629+
expect(onValueChange.mock.lastCall?.[0]).toBe(1.23);
630+
expect(input).toHaveValue(new Intl.NumberFormat(locale, format).format(1.239));
631+
});
632+
});
633+
588634
it('should not throw on blur when format uses roundingIncrement with fixed fraction digits', async () => {
589635
const format = {
590636
minimumFractionDigits: 1,

packages/react/src/number-field/input/NumberFieldInput.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import { formatNumber, formatNumberMaxPrecision } from '../../utils/formatNumber';
3232
import { useValueChanged } from '../../internals/useValueChanged';
3333
import { REASONS } from '../../internals/reasons';
34+
import { roundToFractionDigits } from '../utils/validate';
3435

3536
const stateAttributesMapping = {
3637
...fieldValidityMapping,
@@ -189,7 +190,7 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
189190
const maxFrac = formatOptions?.maximumFractionDigits;
190191
const committed =
191192
hasExplicitPrecision && typeof maxFrac === 'number'
192-
? Number(parsedValue.toFixed(maxFrac))
193+
? roundToFractionDigits(parsedValue, maxFrac, formatOptions)
193194
: parsedValue;
194195

195196
const nextEventDetails = createGenericEventDetails(REASONS.inputBlur, event.nativeEvent);

packages/react/src/number-field/utils/validate.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,25 @@ describe('NumberField validate', () => {
2525
expect(removeFloatingPointErrors(0.2 + 0.1, { maximumFractionDigits: 1 })).toBe(0.3);
2626
});
2727

28+
it('respects roundingMode when maximumFractionDigits is provided', () => {
29+
expect(
30+
removeFloatingPointErrors(1.239, {
31+
maximumFractionDigits: 2,
32+
roundingMode: 'floor',
33+
}),
34+
).toBe(1.23);
35+
});
36+
37+
it('respects roundingIncrement when maximumFractionDigits is provided', () => {
38+
expect(
39+
removeFloatingPointErrors(1.26, {
40+
minimumFractionDigits: 1,
41+
maximumFractionDigits: 1,
42+
roundingIncrement: 5,
43+
}),
44+
).toBe(1.5);
45+
});
46+
2847
it('returns 1000 for 1000, ignoring grouping', () => {
2948
expect(removeFloatingPointErrors(1000)).toBe(1000);
3049
});

packages/react/src/number-field/utils/validate.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,39 @@ import { getFormatter } from '../../utils/formatNumber';
33

44
const STEP_EPSILON_FACTOR = 1e-10;
55

6-
function getFractionDigits(format?: Intl.NumberFormatOptions) {
6+
// The repo's configured Intl types do not include the newer NumberFormat v3 rounding options yet.
7+
type NumberFormatOptionsWithRounding = Intl.NumberFormatOptions & {
8+
roundingIncrement?:
9+
| 1
10+
| 2
11+
| 5
12+
| 10
13+
| 20
14+
| 25
15+
| 50
16+
| 100
17+
| 200
18+
| 250
19+
| 500
20+
| 1000
21+
| 2000
22+
| 2500
23+
| 5000
24+
| undefined;
25+
roundingMode?:
26+
| 'ceil'
27+
| 'floor'
28+
| 'expand'
29+
| 'trunc'
30+
| 'halfCeil'
31+
| 'halfFloor'
32+
| 'halfExpand'
33+
| 'halfTrunc'
34+
| 'halfEven'
35+
| undefined;
36+
};
37+
38+
function getFractionDigits(format?: NumberFormatOptionsWithRounding) {
739
const defaultOptions = getFormatter('en-US').resolvedOptions();
840
const minimumFractionDigits =
941
format?.minimumFractionDigits ?? defaultOptions.minimumFractionDigits ?? 0;
@@ -14,17 +46,34 @@ function getFractionDigits(format?: Intl.NumberFormatOptions) {
1446
return { maximumFractionDigits, minimumFractionDigits };
1547
}
1648

17-
function roundToFractionDigits(value: number, maximumFractionDigits: number) {
49+
export function roundToFractionDigits(
50+
value: number,
51+
maximumFractionDigits: number,
52+
format?: NumberFormatOptionsWithRounding,
53+
) {
1854
if (!Number.isFinite(value)) {
1955
return value;
2056
}
2157
const digits = Math.min(Math.max(maximumFractionDigits, 0), 20);
22-
return Number(value.toFixed(digits));
58+
59+
if (format?.roundingIncrement == null && format?.roundingMode == null) {
60+
return Number(value.toFixed(digits));
61+
}
62+
63+
const roundingFormatOptions: NumberFormatOptionsWithRounding = {
64+
useGrouping: false,
65+
minimumFractionDigits: digits,
66+
maximumFractionDigits: digits,
67+
roundingIncrement: format?.roundingIncrement,
68+
roundingMode: format?.roundingMode,
69+
};
70+
71+
return Number(getFormatter('en-US', roundingFormatOptions).format(value));
2372
}
2473

25-
export function removeFloatingPointErrors(value: number, format?: Intl.NumberFormatOptions) {
74+
export function removeFloatingPointErrors(value: number, format?: NumberFormatOptionsWithRounding) {
2675
const { maximumFractionDigits } = getFractionDigits(format);
27-
return roundToFractionDigits(value, maximumFractionDigits);
76+
return roundToFractionDigits(value, maximumFractionDigits, format);
2877
}
2978

3079
function snapToStep(
@@ -73,7 +122,7 @@ export function toValidatedNumber(
73122
minWithDefault: number;
74123
maxWithDefault: number;
75124
minWithZeroDefault: number;
76-
format: Intl.NumberFormatOptions | undefined;
125+
format: NumberFormatOptionsWithRounding | undefined;
77126
snapOnStep: boolean;
78127
small: boolean;
79128
clamp: boolean;

0 commit comments

Comments
 (0)