Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions packages/react/src/number-field/input/NumberFieldInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,61 @@ describe('<NumberField.Input />', () => {
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<number | null>(null);
return (
<NumberField.Root
value={value}
onValueChange={(nextValue) => {
onValueChange(nextValue);
setValue(nextValue);
}}
format={format}
locale={locale}
>
<NumberField.Input />
</NumberField.Root>
);
}

const { user } = await render(<Controlled />);
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,
Expand All @@ -611,6 +666,111 @@ describe('<NumberField.Input />', () => {
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(
<NumberField.Root
format={format}
onValueChange={onValueChange}
onValueCommitted={onValueCommitted}
>
<NumberField.Input />
</NumberField.Root>,
);

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();

Expand Down
9 changes: 4 additions & 5 deletions packages/react/src/number-field/input/NumberFieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions packages/react/src/number-field/utils/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/number-field/utils/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export function parseNumber(
num /= 100;
}

if (Number.isNaN(num)) {
if (!Number.isFinite(num)) {
return null;
}

Expand Down
121 changes: 121 additions & 0 deletions packages/react/src/number-field/utils/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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, {
Expand Down
Loading
Loading