Skip to content

Commit 3dbc0e3

Browse files
authored
[number field] Improve parsing logic (#2725)
1 parent a79a656 commit 3dbc0e3

File tree

6 files changed

+669
-64
lines changed

6 files changed

+669
-64
lines changed

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

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,81 @@ describe('<NumberField.Input />', () => {
108108
expect(input).to.have.value('10');
109109
});
110110

111+
it('allows unicode plus/minus, permille and fullwidth digits on keydown', async () => {
112+
await render(
113+
<NumberField.Root>
114+
<NumberField.Input />
115+
</NumberField.Root>,
116+
);
117+
118+
const input = screen.getByRole('textbox');
119+
await act(async () => input.focus());
120+
121+
function dispatchKey(key: string) {
122+
const evt = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
123+
return input.dispatchEvent(evt);
124+
}
125+
126+
expect(dispatchKey('−')).to.equal(true); // MINUS SIGN U+2212
127+
expect(dispatchKey('+')).to.equal(true); // FULLWIDTH PLUS SIGN U+FF0B
128+
expect(dispatchKey('‰')).to.equal(true);
129+
expect(dispatchKey('1')).to.equal(true);
130+
});
131+
132+
it('applies locale-aware decimal/group gating (de-DE)', async () => {
133+
await render(
134+
<NumberField.Root locale="de-DE">
135+
<NumberField.Input />
136+
</NumberField.Root>,
137+
);
138+
139+
const input = screen.getByRole('textbox');
140+
await act(async () => input.focus());
141+
142+
const dispatchKey = (key: string) => {
143+
const evt = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
144+
return input.dispatchEvent(evt);
145+
};
146+
147+
// de-DE: decimal is ',' and group is '.'
148+
// First comma is allowed
149+
expect(dispatchKey(',')).to.equal(true);
150+
// Simulate a typical user value with a digit before decimal to let change handler accept it
151+
fireEvent.change(input, { target: { value: '1,' } });
152+
expect(input).to.have.value('1,');
153+
154+
// Second comma should be blocked
155+
expect(dispatchKey(',')).to.equal(false);
156+
157+
// Grouping '.' should be allowed multiple times
158+
expect(dispatchKey('.')).to.equal(true);
159+
fireEvent.change(input, { target: { value: '1.,' } });
160+
expect(dispatchKey('.')).to.equal(true);
161+
});
162+
163+
it('allows space key when locale uses space-like grouping (pl-PL)', async () => {
164+
await render(
165+
<NumberField.Root locale="pl-PL">
166+
<NumberField.Input />
167+
</NumberField.Root>,
168+
);
169+
170+
const input = screen.getByRole('textbox');
171+
await act(async () => input.focus());
172+
173+
const dispatchKey = (key: string) => {
174+
const evt = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
175+
return input.dispatchEvent(evt);
176+
};
177+
178+
// pl-PL grouping is a space-like character; typing plain space from keyboard should be allowed
179+
expect(dispatchKey(' ')).to.equal(true);
180+
181+
// Simulate a typical user value using a regular space as group
182+
fireEvent.change(input, { target: { value: '1 234' } });
183+
expect(input).to.have.value('1 234');
184+
});
185+
111186
it('commits formatted value only on blur', async () => {
112187
await render(
113188
<NumberField.Root>
@@ -544,6 +619,29 @@ describe('<NumberField.Input />', () => {
544619
// Without explicit precision formatting, the behavior depends on the step
545620
// The current implementation preserves full precision until it differs from canonical
546621
expect(input).to.have.value((1.235).toLocaleString(undefined, { minimumFractionDigits: 3 }));
547-
expect(onValueChange.callCount).to.equal(callCountBeforeBlur);
622+
expect(onValueChange.callCount).to.equal(callCountBeforeBlur + 1);
623+
});
624+
625+
it('commits parsed value on blur and normalizes display for fr-FR', async () => {
626+
const onValueChange = spy();
627+
628+
await render(
629+
<NumberField.Root locale="fr-FR" onValueChange={onValueChange}>
630+
<NumberField.Input />
631+
</NumberField.Root>,
632+
);
633+
634+
const input = screen.getByRole<HTMLInputElement>('textbox');
635+
await act(async () => input.focus());
636+
637+
fireEvent.change(input, { target: { value: '1234,5' } });
638+
expect(input).to.have.value('1234,5');
639+
640+
fireEvent.blur(input);
641+
642+
expect(onValueChange.callCount).to.equal(1);
643+
expect(onValueChange.firstCall.args[0]).to.equal(1234.5);
644+
645+
expect(input.value).to.equal((1234.5).toLocaleString('fr-FR'));
548646
});
549647
});

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

Lines changed: 66 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,17 @@ import { useFieldRootContext } from '../../field/root/FieldRootContext';
88
import { useFieldControlValidation } from '../../field/control/useFieldControlValidation';
99
import { fieldValidityMapping } from '../../field/utils/constants';
1010
import { DEFAULT_STEP } from '../utils/constants';
11-
import { ARABIC_RE, HAN_RE, getNumberLocaleDetails, parseNumber } from '../utils/parse';
11+
import {
12+
ARABIC_DETECT_RE,
13+
HAN_DETECT_RE,
14+
FULLWIDTH_DETECT_RE,
15+
getNumberLocaleDetails,
16+
parseNumber,
17+
ANY_MINUS_RE,
18+
ANY_PLUS_RE,
19+
ANY_MINUS_DETECT_RE,
20+
ANY_PLUS_DETECT_RE,
21+
} from '../utils/parse';
1222
import type { NumberFieldRoot } from '../root/NumberFieldRoot';
1323
import { stateAttributesMapping as numberFieldStateAttributesMapping } from '../utils/stateAttributesMapping';
1424
import { useField } from '../../field/useField';
@@ -171,43 +181,38 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
171181

172182
const formatOptions = formatOptionsRef.current;
173183
const parsedValue = parseNumber(inputValue, locale, formatOptions);
174-
const canonicalText = formatNumber(parsedValue, locale, formatOptions);
175-
const maxPrecisionText = formatNumberMaxPrecision(parsedValue, locale, formatOptions);
176-
const canonical = parseNumber(canonicalText, locale, formatOptions);
177-
const maxPrecision = parseNumber(maxPrecisionText, locale, formatOptions);
178-
179184
if (parsedValue === null) {
180185
return;
181186
}
182187

183188
blockRevalidationRef.current = true;
184189

185-
if (validationMode === 'onBlur') {
186-
commitValidation(canonical);
187-
}
188-
190+
// If an explicit precision is requested, round the committed numeric value.
189191
const hasExplicitPrecision =
190192
formatOptions?.maximumFractionDigits != null ||
191193
formatOptions?.minimumFractionDigits != null;
192194

193-
if (hasExplicitPrecision) {
194-
// When the consumer explicitly requests a precision, always round the number to that
195-
// precision and normalize the displayed text accordingly.
196-
if (value !== canonical) {
197-
setValue(canonical, event.nativeEvent);
198-
}
199-
if (inputValue !== canonicalText) {
200-
setInputValue(canonicalText);
201-
}
202-
} else if (value !== maxPrecision) {
203-
// Default behaviour: preserve max precision until it differs from canonical
204-
setValue(canonical, event.nativeEvent);
205-
} else {
206-
const shouldPreserveFullPrecision =
207-
parsedValue === value && inputValue === maxPrecisionText;
208-
if (!shouldPreserveFullPrecision && inputValue !== canonicalText) {
209-
setInputValue(canonicalText);
210-
}
195+
const maxFrac = formatOptions?.maximumFractionDigits;
196+
const committed =
197+
hasExplicitPrecision && typeof maxFrac === 'number'
198+
? Number(parsedValue.toFixed(maxFrac))
199+
: parsedValue;
200+
201+
if (validationMode === 'onBlur') {
202+
commitValidation(committed);
203+
}
204+
if (value !== committed) {
205+
setValue(committed, event.nativeEvent);
206+
}
207+
208+
// Normalize only the displayed text
209+
const canonicalText = formatNumber(committed, locale, formatOptions);
210+
const maxPrecisionText = formatNumberMaxPrecision(parsedValue, locale, formatOptions);
211+
const shouldPreserveFullPrecision =
212+
!hasExplicitPrecision && parsedValue === value && inputValue === maxPrecisionText;
213+
214+
if (!shouldPreserveFullPrecision && inputValue !== canonicalText) {
215+
setInputValue(canonicalText);
211216
}
212217
},
213218
onChange(event) {
@@ -251,20 +256,42 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
251256
let isAllowedNonNumericKey = allowedNonNumericKeys.has(event.key);
252257

253258
const { decimal, currency, percentSign } = getNumberLocaleDetails(
254-
[],
259+
locale,
255260
formatOptionsRef.current,
256261
);
257262

258263
const selectionStart = event.currentTarget.selectionStart;
259264
const selectionEnd = event.currentTarget.selectionEnd;
260265
const isAllSelected = selectionStart === 0 && selectionEnd === inputValue.length;
261266

262-
// Allow the minus key only if there isn't already a plus or minus sign, or if all the text
263-
// is selected, or if only the minus sign is highlighted.
264-
if (event.key === '-' && allowedNonNumericKeys.has('-')) {
265-
const isMinusHighlighted =
266-
selectionStart === 0 && selectionEnd === 1 && inputValue[0] === '-';
267-
isAllowedNonNumericKey = !inputValue.includes('-') || isAllSelected || isMinusHighlighted;
267+
// Normalize handling of plus/minus signs via precomputed regexes
268+
const selectionIsExactlyCharAt = (index: number) =>
269+
selectionStart === index && selectionEnd === index + 1;
270+
271+
if (
272+
ANY_MINUS_DETECT_RE.test(event.key) &&
273+
Array.from(allowedNonNumericKeys).some((k) => ANY_MINUS_DETECT_RE.test(k || ''))
274+
) {
275+
// Only allow one sign unless replacing the existing one or all text is selected
276+
const existingIndex = inputValue.search(ANY_MINUS_RE);
277+
const isReplacingExisting =
278+
existingIndex != null && existingIndex !== -1 && selectionIsExactlyCharAt(existingIndex);
279+
isAllowedNonNumericKey =
280+
!(ANY_MINUS_DETECT_RE.test(inputValue) || ANY_PLUS_DETECT_RE.test(inputValue)) ||
281+
isAllSelected ||
282+
isReplacingExisting;
283+
}
284+
if (
285+
ANY_PLUS_DETECT_RE.test(event.key) &&
286+
Array.from(allowedNonNumericKeys).some((k) => ANY_PLUS_DETECT_RE.test(k || ''))
287+
) {
288+
const existingIndex = inputValue.search(ANY_PLUS_RE);
289+
const isReplacingExisting =
290+
existingIndex != null && existingIndex !== -1 && selectionIsExactlyCharAt(existingIndex);
291+
isAllowedNonNumericKey =
292+
!(ANY_MINUS_DETECT_RE.test(inputValue) || ANY_PLUS_DETECT_RE.test(inputValue)) ||
293+
isAllSelected ||
294+
isReplacingExisting;
268295
}
269296

270297
// Only allow one of each symbol.
@@ -279,8 +306,9 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
279306
});
280307

281308
const isLatinNumeral = /^[0-9]$/.test(event.key);
282-
const isArabicNumeral = ARABIC_RE.test(event.key);
283-
const isHanNumeral = HAN_RE.test(event.key);
309+
const isArabicNumeral = ARABIC_DETECT_RE.test(event.key);
310+
const isHanNumeral = HAN_DETECT_RE.test(event.key);
311+
const isFullwidthNumeral = FULLWIDTH_DETECT_RE.test(event.key);
284312
const isNavigateKey = NAVIGATE_KEYS.has(event.key);
285313

286314
if (
@@ -294,6 +322,7 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
294322
isAllowedNonNumericKey ||
295323
isLatinNumeral ||
296324
isArabicNumeral ||
325+
isFullwidthNumeral ||
297326
isHanNumeral ||
298327
isNavigateKey
299328
) {

0 commit comments

Comments
 (0)