Skip to content

Commit ca1af48

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

4 files changed

Lines changed: 167 additions & 7 deletions

File tree

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

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

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

669+
it.each([
670+
[
671+
'percent',
672+
{
673+
style: 'percent',
674+
maximumFractionDigits: 2,
675+
roundingMode: 'floor',
676+
},
677+
0.0123,
678+
],
679+
[
680+
'unit percent',
681+
{
682+
style: 'unit',
683+
unit: 'percent',
684+
maximumFractionDigits: 2,
685+
roundingMode: 'floor',
686+
},
687+
1.23,
688+
],
689+
] as const)('should round %s values on blur', async (_, format, expectedValue) => {
690+
const { input, onValueChange, user } = await renderControlledNumberField(format);
691+
692+
await user.keyboard('1.239%');
693+
fireEvent.blur(input);
694+
695+
expect(onValueChange.mock.lastCall?.[0]).toBe(expectedValue);
696+
expect(input).toHaveValue('1.23%');
697+
});
698+
614699
it('should round to step precision on blur when step implies precision constraints', async () => {
615700
const onValueChange = vi.fn();
616701

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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,52 @@ 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('rounds percent values at display scale when maximumFractionDigits is provided', () => {
38+
expect(
39+
removeFloatingPointErrors(0.01236, {
40+
style: 'percent',
41+
maximumFractionDigits: 2,
42+
}),
43+
).toBe(0.0124);
44+
expect(
45+
removeFloatingPointErrors(0.01239, {
46+
style: 'percent',
47+
maximumFractionDigits: 2,
48+
roundingMode: 'floor',
49+
}),
50+
).toBe(0.0123);
51+
});
52+
53+
it('does not scale unit percent values when maximumFractionDigits is provided', () => {
54+
expect(
55+
removeFloatingPointErrors(1.239, {
56+
style: 'unit',
57+
unit: 'percent',
58+
maximumFractionDigits: 2,
59+
roundingMode: 'floor',
60+
}),
61+
).toBe(1.23);
62+
});
63+
64+
it('respects roundingIncrement when maximumFractionDigits is provided', () => {
65+
expect(
66+
removeFloatingPointErrors(1.26, {
67+
minimumFractionDigits: 1,
68+
maximumFractionDigits: 1,
69+
roundingIncrement: 5,
70+
}),
71+
).toBe(1.5);
72+
});
73+
2874
it('returns 1000 for 1000, ignoring grouping', () => {
2975
expect(removeFloatingPointErrors(1000)).toBe(1000);
3076
});

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

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ 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?: number | undefined;
9+
roundingMode?: string | undefined;
10+
};
11+
12+
function getFractionDigits(format?: NumberFormatOptionsWithRounding) {
713
const defaultOptions = getFormatter('en-US').resolvedOptions();
814
const minimumFractionDigits =
915
format?.minimumFractionDigits ?? defaultOptions.minimumFractionDigits ?? 0;
@@ -14,17 +20,39 @@ function getFractionDigits(format?: Intl.NumberFormatOptions) {
1420
return { maximumFractionDigits, minimumFractionDigits };
1521
}
1622

17-
function roundToFractionDigits(value: number, maximumFractionDigits: number) {
23+
export function roundToFractionDigits(
24+
value: number,
25+
maximumFractionDigits: number,
26+
format?: NumberFormatOptionsWithRounding,
27+
) {
1828
if (!Number.isFinite(value)) {
1929
return value;
2030
}
2131
const digits = Math.min(Math.max(maximumFractionDigits, 0), 20);
22-
return Number(value.toFixed(digits));
32+
const isPercentWithExplicitPrecision =
33+
format?.style === 'percent' &&
34+
(format.maximumFractionDigits != null || format.minimumFractionDigits != null);
35+
const scale = isPercentWithExplicitPrecision ? 100 : 1;
36+
const valueToRound = value * scale;
37+
38+
if (format?.roundingIncrement == null && format?.roundingMode == null) {
39+
return Number(valueToRound.toFixed(digits)) / scale;
40+
}
41+
42+
const roundingFormatOptions: NumberFormatOptionsWithRounding = {
43+
useGrouping: false,
44+
minimumFractionDigits: digits,
45+
maximumFractionDigits: digits,
46+
roundingIncrement: format?.roundingIncrement,
47+
roundingMode: format?.roundingMode,
48+
};
49+
50+
return Number(getFormatter('en-US', roundingFormatOptions).format(valueToRound)) / scale;
2351
}
2452

25-
export function removeFloatingPointErrors(value: number, format?: Intl.NumberFormatOptions) {
53+
export function removeFloatingPointErrors(value: number, format?: NumberFormatOptionsWithRounding) {
2654
const { maximumFractionDigits } = getFractionDigits(format);
27-
return roundToFractionDigits(value, maximumFractionDigits);
55+
return roundToFractionDigits(value, maximumFractionDigits, format);
2856
}
2957

3058
function snapToStep(
@@ -73,7 +101,7 @@ export function toValidatedNumber(
73101
minWithDefault: number;
74102
maxWithDefault: number;
75103
minWithZeroDefault: number;
76-
format: Intl.NumberFormatOptions | undefined;
104+
format: NumberFormatOptionsWithRounding | undefined;
77105
snapOnStep: boolean;
78106
small: boolean;
79107
clamp: boolean;

0 commit comments

Comments
 (0)