Skip to content

Commit bfa245c

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

4 files changed

Lines changed: 156 additions & 7 deletions

File tree

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

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

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

659+
it('should round percent values to explicit display fraction digits on blur', async () => {
660+
const onValueChange = vi.fn();
661+
const format = {
662+
style: 'percent',
663+
maximumFractionDigits: 2,
664+
roundingMode: 'floor',
665+
} as const;
666+
667+
function Controlled() {
668+
const [value, setValue] = React.useState<number | null>(null);
669+
return (
670+
<NumberField.Root
671+
value={value}
672+
onValueChange={(nextValue) => {
673+
onValueChange(nextValue);
674+
setValue(nextValue);
675+
}}
676+
format={format}
677+
locale="en-US"
678+
>
679+
<NumberField.Input />
680+
</NumberField.Root>
681+
);
682+
}
683+
684+
const { user } = await render(<Controlled />);
685+
const input = screen.getByRole('textbox');
686+
687+
await act(async () => {
688+
input.focus();
689+
});
690+
691+
await user.keyboard('1.239%');
692+
fireEvent.blur(input);
693+
694+
expect(onValueChange.mock.lastCall?.[0]).toBe(0.0123);
695+
expect(input).toHaveValue('1.23%');
696+
});
697+
614698
it('should round to step precision on blur when step implies precision constraints', async () => {
615699
const onValueChange = vi.fn();
616700

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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,41 @@ 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('respects roundingIncrement when maximumFractionDigits is provided', () => {
54+
expect(
55+
removeFloatingPointErrors(1.26, {
56+
minimumFractionDigits: 1,
57+
maximumFractionDigits: 1,
58+
roundingIncrement: 5,
59+
}),
60+
).toBe(1.5);
61+
});
62+
2863
it('returns 1000 for 1000, ignoring grouping', () => {
2964
expect(removeFloatingPointErrors(1000)).toBe(1000);
3065
});

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

Lines changed: 35 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,40 @@ 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 scale =
33+
format?.style === 'percent' &&
34+
(format.maximumFractionDigits != null || format.minimumFractionDigits != null)
35+
? 100
36+
: 1;
37+
const valueToRound = value * scale;
38+
39+
if (format?.roundingIncrement == null && format?.roundingMode == null) {
40+
return Number(valueToRound.toFixed(digits)) / scale;
41+
}
42+
43+
const roundingFormatOptions: NumberFormatOptionsWithRounding = {
44+
useGrouping: false,
45+
minimumFractionDigits: digits,
46+
maximumFractionDigits: digits,
47+
roundingIncrement: format?.roundingIncrement,
48+
roundingMode: format?.roundingMode,
49+
};
50+
51+
return Number(getFormatter('en-US', roundingFormatOptions).format(valueToRound)) / scale;
2352
}
2453

25-
export function removeFloatingPointErrors(value: number, format?: Intl.NumberFormatOptions) {
54+
export function removeFloatingPointErrors(value: number, format?: NumberFormatOptionsWithRounding) {
2655
const { maximumFractionDigits } = getFractionDigits(format);
27-
return roundToFractionDigits(value, maximumFractionDigits);
56+
return roundToFractionDigits(value, maximumFractionDigits, format);
2857
}
2958

3059
function snapToStep(
@@ -73,7 +102,7 @@ export function toValidatedNumber(
73102
minWithDefault: number;
74103
maxWithDefault: number;
75104
minWithZeroDefault: number;
76-
format: Intl.NumberFormatOptions | undefined;
105+
format: NumberFormatOptionsWithRounding | undefined;
77106
snapOnStep: boolean;
78107
small: boolean;
79108
clamp: boolean;

0 commit comments

Comments
 (0)