Skip to content

Commit f459439

Browse files
committed
handle ambiguous group vs decimal case
1 parent 9ddff90 commit f459439

File tree

3 files changed

+72
-14
lines changed

3 files changed

+72
-14
lines changed

packages/@internationalized/number/src/NumberParser.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,17 @@ class NumberParserImpl {
132132
}
133133

134134
parse(value: string) {
135+
let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping;
135136
// to parse the number, we need to remove anything that isn't actually part of the number, for example we want '-10.40' not '-10.40 USD'
136137
let fullySanitizedValue = this.sanitize(value);
137138

138-
if (this.symbols.group) {
139-
// Remove group characters, and replace decimal points and numerals with ASCII values.
140-
fullySanitizedValue = replaceAll(fullySanitizedValue, this.symbols.group, '');
139+
// Return NaN if there is a group symbol but useGrouping is false
140+
if (!isGroupSymbolAllowed && this.symbols.group && fullySanitizedValue.includes(this.symbols.group)) {
141+
return NaN;
142+
} else if (this.symbols.group) {
143+
fullySanitizedValue = fullySanitizedValue.replaceAll(this.symbols.group!, '');
141144
}
145+
142146
if (this.symbols.decimal) {
143147
fullySanitizedValue = fullySanitizedValue.replace(this.symbols.decimal!, '.');
144148
}
@@ -191,11 +195,11 @@ class NumberParserImpl {
191195
if (this.options.currencySign === 'accounting' && CURRENCY_SIGN_REGEX.test(value)) {
192196
newValue = -1 * newValue;
193197
}
194-
195198
return newValue;
196199
}
197200

198201
sanitize(value: string) {
202+
let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping;
199203
// If the value is only a unit and it matches one of the formatted numbers where the value is part of the unit and doesn't have any numerals, then
200204
// return the known value for that case.
201205
if (this.symbols.noNumeralUnits.length > 0 && this.symbols.noNumeralUnits.find(obj => obj.unit === value)) {
@@ -220,7 +224,6 @@ class NumberParserImpl {
220224
value = value.replace(this.symbols.literals, '');
221225
}
222226

223-
224227
// Replace the ASCII minus sign with the minus sign used in the current locale
225228
// so that both are allowed in case the user's keyboard doesn't have the locale's minus sign.
226229
if (this.symbols.minusSign) {
@@ -234,27 +237,27 @@ class NumberParserImpl {
234237
value = replaceAll(value, ',', this.symbols.decimal);
235238
value = replaceAll(value, String.fromCharCode(1548), this.symbols.decimal);
236239
}
237-
if (this.symbols.group) {
240+
if (this.symbols.group && isGroupSymbolAllowed) {
238241
value = replaceAll(value, '.', this.symbols.group);
239242
}
240243
}
241244

242245
// In some locale styles, such as swiss currency, the group character can be a special single quote
243246
// that keyboards don't typically have. This expands the character to include the easier to type single quote.
244-
if (this.symbols.group === '’' && value.includes("'")) {
247+
if (this.symbols.group === '’' && value.includes("'") && isGroupSymbolAllowed) {
245248
value = replaceAll(value, "'", this.symbols.group);
246249
}
247250

248251
// fr-FR group character is narrow non-breaking space, char code 8239 (U+202F), but that's not a key on the french keyboard,
249252
// so allow space and non-breaking space as a group char as well
250-
if (this.options.locale === 'fr-FR' && this.symbols.group) {
253+
if (this.options.locale === 'fr-FR' && this.symbols.group && isGroupSymbolAllowed) {
251254
value = replaceAll(value, ' ', this.symbols.group);
252255
value = replaceAll(value, /\u00A0/g, this.symbols.group);
253256
}
254257

255258
// If there are multiple decimal separators and only one group separator, swap them
256259
if (this.symbols.decimal
257-
&& this.symbols.group
260+
&& (this.symbols.group && isGroupSymbolAllowed)
258261
&& [...value.matchAll(new RegExp(escapeRegex(this.symbols.decimal), 'g'))].length > 1
259262
&& [...value.matchAll(new RegExp(escapeRegex(this.symbols.group), 'g'))].length <= 1) {
260263
value = swapCharacters(value, this.symbols.decimal, this.symbols.group);
@@ -263,7 +266,7 @@ class NumberParserImpl {
263266
// If the decimal separator is before the group separator, swap them
264267
let decimalIndex = value.indexOf(this.symbols.decimal!);
265268
let groupIndex = value.indexOf(this.symbols.group!);
266-
if (this.symbols.decimal && this.symbols.group && decimalIndex > -1 && groupIndex > -1 && decimalIndex < groupIndex) {
269+
if (this.symbols.decimal && (this.symbols.group && isGroupSymbolAllowed) && decimalIndex > -1 && groupIndex > -1 && decimalIndex < groupIndex) {
267270
value = swapCharacters(value, this.symbols.decimal, this.symbols.group);
268271
}
269272

@@ -286,13 +289,13 @@ class NumberParserImpl {
286289
let areOnlyGroupAndDecimalSymbols = [...nonDigits].every(char => allPossibleGroupAndDecimalSymbols.has(char));
287290
let oneSymbolNotMatching = (
288291
nonDigits.size === 2
289-
&& this.symbols.group
292+
&& (this.symbols.group && isGroupSymbolAllowed)
290293
&& this.symbols.decimal
291294
&& (!nonDigits.has(this.symbols.group!) || !nonDigits.has(this.symbols.decimal!))
292295
);
293296
let bothSymbolsNotMatching = (
294297
nonDigits.size === 2
295-
&& this.symbols.group
298+
&& (this.symbols.group && isGroupSymbolAllowed)
296299
&& this.symbols.decimal
297300
&& !nonDigits.has(this.symbols.group!) && !nonDigits.has(this.symbols.decimal!)
298301
);
@@ -318,6 +321,7 @@ class NumberParserImpl {
318321
}
319322

320323
isValidPartialNumber(value: string, minValue: number = -Infinity, maxValue: number = Infinity): boolean {
324+
let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping;
321325
value = this.sanitize(value);
322326

323327
// Remove minus or plus sign, which must be at the start of the string.
@@ -333,7 +337,7 @@ class NumberParserImpl {
333337
}
334338

335339
// Remove numerals, groups, and decimals
336-
if (this.symbols.group) {
340+
if (this.symbols.group && isGroupSymbolAllowed) {
337341
value = replaceAll(value, this.symbols.group, '');
338342
}
339343
value = value.replace(this.symbols.numeral, '');
@@ -366,7 +370,8 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I
366370
maximumSignificantDigits: 21,
367371
roundingIncrement: 1,
368372
roundingPriority: 'auto',
369-
roundingMode: 'halfExpand'
373+
roundingMode: 'halfExpand',
374+
useGrouping: true
370375
});
371376
// Note: some locale's don't add a group symbol until there is a ten thousands place
372377
let allParts = symbolFormatter.formatToParts(-10000.111);

packages/@internationalized/number/test/NumberParser.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ describe('NumberParser', function () {
6161
expect(new NumberParser('en-US', {style: 'decimal'}).parse('1abc')).toBe(NaN);
6262
});
6363

64+
it('should return NaN for invalid grouping', function () {
65+
expect(new NumberParser('en-US', {useGrouping: false}).parse('1234,7')).toBeNaN();
66+
expect(new NumberParser('de-DE', {useGrouping: false}).parse('1234.7')).toBeNaN();
67+
});
68+
6469
describe('currency', function () {
6570
it('should parse without the currency symbol', function () {
6671
expect(new NumberParser('en-US', {currency: 'USD', style: 'currency'}).parse('10.50')).toBe(10.5);
@@ -370,6 +375,19 @@ describe('NumberParser', function () {
370375
const formattedOnce = formatter.format(0.0095);
371376
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
372377
});
378+
it('should handle non-grouping in russian locale', () => {
379+
let locale = 'ru-RU';
380+
let options = {
381+
style: 'percent',
382+
useGrouping: false,
383+
minimumFractionDigits: undefined,
384+
maximumFractionDigits: undefined
385+
};
386+
const formatter = new Intl.NumberFormat(locale, options);
387+
const parser = new NumberParser(locale, options);
388+
const formattedOnce = formatter.format(2.220446049250313e-16);
389+
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
390+
});
373391
});
374392
});
375393

@@ -406,6 +424,11 @@ describe('NumberParser', function () {
406424
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000,000')).toBe(true);
407425
});
408426

427+
it('should return false for invalid grouping', function () {
428+
expect(new NumberParser('en-US', {useGrouping: false}).isValidPartialNumber('1234,7')).toBe(false);
429+
expect(new NumberParser('de-DE', {useGrouping: false}).isValidPartialNumber('1234.7')).toBe(false);
430+
});
431+
409432
it('should reject random characters', function () {
410433
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('g')).toBe(false);
411434
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1abc')).toBe(false);

packages/react-aria-components/test/NumberField.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,34 @@ describe('NumberField', () => {
258258
expect(input).toHaveValue('يومان');
259259
expect(onChange).toHaveBeenLastCalledWith(2);
260260
});
261+
262+
it('should not type the grouping characters when useGrouping is false', async () => {
263+
let {getByRole} = render(<TestNumberField formatOptions={{useGrouping: false}} />);
264+
let input = getByRole('textbox');
265+
266+
await user.keyboard('102,4');
267+
expect(input).toHaveAttribute('value', '1024');
268+
269+
await user.clear(input);
270+
expect(input).toHaveAttribute('value', '');
271+
272+
await user.paste('102,4');
273+
await user.tab();
274+
expect(input).toHaveAttribute('value', '');
275+
});
276+
277+
it('should not type the grouping characters when useGrouping is false and in German locale', async () => {
278+
let {getByRole} = render(<I18nProvider locale="de-DE"><TestNumberField formatOptions={{useGrouping: false}} /></I18nProvider>);
279+
let input = getByRole('textbox');
280+
281+
await user.keyboard('102.4');
282+
expect(input).toHaveAttribute('value', '1024');
283+
284+
await user.clear(input);
285+
expect(input).toHaveAttribute('value', '');
286+
287+
await user.paste('102.4');
288+
await user.tab();
289+
expect(input).toHaveAttribute('value', '');
290+
});
261291
});

0 commit comments

Comments
 (0)