Skip to content

Commit b1d887c

Browse files
authored
feat: NumberParser assorted bug fixes (#8592)
* fix: NumberParsing * fix test usage * fuzzier matching for numbers * fix ambiguous case with leading zeroes * fix last breaking tests * fix case of formatted numbers with no numerals * fix cases of numbers with no numerals * remove ambiguous case but allow for numbers to start with group characters * explanation * handle ambiguous group vs decimal case * Revert "handle ambiguous group vs decimal case" This reverts commit f459439. * Reapply "handle ambiguous group vs decimal case" This reverts commit 6495950. * simplify * add test for 5927 * remove extra handling
1 parent 4e6456b commit b1d887c

5 files changed

Lines changed: 285 additions & 26 deletions

File tree

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

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ interface Symbols {
1919
group?: string,
2020
literals: RegExp,
2121
numeral: RegExp,
22-
index: (v: string) => string
22+
numerals: string[],
23+
index: (v: string) => string,
24+
noNumeralUnits: Array<{unit: string, value: number}>
2325
}
2426

2527
const CURRENCY_SIGN_REGEX = new RegExp('^.*\\(.*\\).*$');
@@ -130,13 +132,17 @@ class NumberParserImpl {
130132
}
131133

132134
parse(value: string) {
135+
let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping;
133136
// 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'
134137
let fullySanitizedValue = this.sanitize(value);
135138

136-
if (this.symbols.group) {
137-
// Remove group characters, and replace decimal points and numerals with ASCII values.
138-
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!, '');
139144
}
145+
140146
if (this.symbols.decimal) {
141147
fullySanitizedValue = fullySanitizedValue.replace(this.symbols.decimal!, '.');
142148
}
@@ -189,12 +195,17 @@ class NumberParserImpl {
189195
if (this.options.currencySign === 'accounting' && CURRENCY_SIGN_REGEX.test(value)) {
190196
newValue = -1 * newValue;
191197
}
192-
193198
return newValue;
194199
}
195200

196201
sanitize(value: string) {
197-
// Remove literals and whitespace, which are allowed anywhere in the string
202+
let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping;
203+
// 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
204+
// return the known value for that case.
205+
if (this.symbols.noNumeralUnits.length > 0 && this.symbols.noNumeralUnits.find(obj => obj.unit === value)) {
206+
return this.symbols.noNumeralUnits.find(obj => obj.unit === value)!.value.toString();
207+
}
208+
198209
value = value.replace(this.symbols.literals, '');
199210

200211
// Replace the ASCII minus sign with the minus sign used in the current locale
@@ -207,23 +218,23 @@ class NumberParserImpl {
207218
// instead they use the , (44) character or apparently the (1548) character.
208219
if (this.options.numberingSystem === 'arab') {
209220
if (this.symbols.decimal) {
210-
value = value.replace(',', this.symbols.decimal);
211-
value = value.replace(String.fromCharCode(1548), this.symbols.decimal);
221+
value = replaceAll(value, ',', this.symbols.decimal);
222+
value = replaceAll(value, String.fromCharCode(1548), this.symbols.decimal);
212223
}
213-
if (this.symbols.group) {
224+
if (this.symbols.group && isGroupSymbolAllowed) {
214225
value = replaceAll(value, '.', this.symbols.group);
215226
}
216227
}
217228

218229
// In some locale styles, such as swiss currency, the group character can be a special single quote
219230
// that keyboards don't typically have. This expands the character to include the easier to type single quote.
220-
if (this.symbols.group === '’' && value.includes("'")) {
231+
if (this.symbols.group === '’' && value.includes("'") && isGroupSymbolAllowed) {
221232
value = replaceAll(value, "'", this.symbols.group);
222233
}
223234

224235
// fr-FR group character is narrow non-breaking space, char code 8239 (U+202F), but that's not a key on the french keyboard,
225236
// so allow space and non-breaking space as a group char as well
226-
if (this.options.locale === 'fr-FR' && this.symbols.group) {
237+
if (this.options.locale === 'fr-FR' && this.symbols.group && isGroupSymbolAllowed) {
227238
value = replaceAll(value, ' ', this.symbols.group);
228239
value = replaceAll(value, /\u00A0/g, this.symbols.group);
229240
}
@@ -232,6 +243,7 @@ class NumberParserImpl {
232243
}
233244

234245
isValidPartialNumber(value: string, minValue: number = -Infinity, maxValue: number = Infinity): boolean {
246+
let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping;
235247
value = this.sanitize(value);
236248

237249
// Remove minus or plus sign, which must be at the start of the string.
@@ -241,18 +253,13 @@ class NumberParserImpl {
241253
value = value.slice(this.symbols.plusSign.length);
242254
}
243255

244-
// Numbers cannot start with a group separator
245-
if (this.symbols.group && value.startsWith(this.symbols.group)) {
246-
return false;
247-
}
248-
249256
// Numbers that can't have any decimal values fail if a decimal character is typed
250257
if (this.symbols.decimal && value.indexOf(this.symbols.decimal) > -1 && this.options.maximumFractionDigits === 0) {
251258
return false;
252259
}
253260

254261
// Remove numerals, groups, and decimals
255-
if (this.symbols.group) {
262+
if (this.symbols.group && isGroupSymbolAllowed) {
256263
value = replaceAll(value, this.symbols.group, '');
257264
}
258265
value = value.replace(this.symbols.numeral, '');
@@ -282,12 +289,21 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I
282289
maximumSignificantDigits: 21,
283290
roundingIncrement: 1,
284291
roundingPriority: 'auto',
285-
roundingMode: 'halfExpand'
292+
roundingMode: 'halfExpand',
293+
useGrouping: true
286294
});
287295
// Note: some locale's don't add a group symbol until there is a ten thousands place
288296
let allParts = symbolFormatter.formatToParts(-10000.111);
289297
let posAllParts = symbolFormatter.formatToParts(10000.111);
290298
let pluralParts = pluralNumbers.map(n => symbolFormatter.formatToParts(n));
299+
// if the plural parts include a unit but no integer or fraction, then we need to add the unit to the special set
300+
let noNumeralUnits = pluralParts.map((p, i) => {
301+
let unit = p.find(p => p.type === 'unit');
302+
if (unit && !p.some(p => p.type === 'integer' || p.type === 'fraction')) {
303+
return {unit: unit.value, value: pluralNumbers[i]};
304+
}
305+
return null;
306+
}).filter(p => !!p);
291307

292308
let minusSign = allParts.find(p => p.type === 'minusSign')?.value ?? '-';
293309
let plusSign = posAllParts.find(p => p.type === 'plusSign')?.value;
@@ -311,17 +327,18 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I
311327
let pluralPartsLiterals = pluralParts.flatMap(p => p.filter(p => !nonLiteralParts.has(p.type)).map(p => escapeRegex(p.value)));
312328
let sortedLiterals = [...new Set([...allPartsLiterals, ...pluralPartsLiterals])].sort((a, b) => b.length - a.length);
313329

330+
// Match both whitespace and formatting characters
314331
let literals = sortedLiterals.length === 0 ?
315-
new RegExp('[\\p{White_Space}]', 'gu') :
316-
new RegExp(`${sortedLiterals.join('|')}|[\\p{White_Space}]`, 'gu');
332+
new RegExp('\\p{White_Space}|\\p{Cf}', 'gu') :
333+
new RegExp(`${sortedLiterals.join('|')}|\\p{White_Space}|\\p{Cf}`, 'gu');
317334

318335
// These are for replacing non-latn characters with the latn equivalent
319336
let numerals = [...new Intl.NumberFormat(intlOptions.locale, {useGrouping: false}).format(9876543210)].reverse();
320337
let indexes = new Map(numerals.map((d, i) => [d, i]));
321338
let numeral = new RegExp(`[${numerals.join('')}]`, 'g');
322339
let index = d => String(indexes.get(d));
323340

324-
return {minusSign, plusSign, decimal, group, literals, numeral, index};
341+
return {minusSign, plusSign, decimal, group, literals, numeral, numerals, index, noNumeralUnits};
325342
}
326343

327344
function replaceAll(str: string, find: string | RegExp, replace: string) {

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

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ describe('NumberParser', function () {
5656
expect(new NumberParser('en-US', {style: 'decimal'}).parse('1abc')).toBe(NaN);
5757
});
5858

59+
it('should return NaN for invalid grouping', function () {
60+
expect(new NumberParser('en-US', {useGrouping: false}).parse('1234,7')).toBeNaN();
61+
expect(new NumberParser('de-DE', {useGrouping: false}).parse('1234.7')).toBeNaN();
62+
});
63+
5964
describe('currency', function () {
6065
it('should parse without the currency symbol', function () {
6166
expect(new NumberParser('en-US', {currency: 'USD', style: 'currency'}).parse('10.50')).toBe(10.5);
@@ -194,8 +199,13 @@ describe('NumberParser', function () {
194199
expect(new NumberParser('de-CH', {style: 'currency', currency: 'CHF'}).parse("CHF 1'000.00")).toBe(1000);
195200
});
196201

202+
it('should parse arabic singular and dual counts', () => {
203+
expect(new NumberParser('ar-AE', {style: 'unit', unit: 'day', unitDisplay: 'long'}).parse('يومان')).toBe(2);
204+
expect(new NumberParser('ar-AE', {style: 'unit', unit: 'day', unitDisplay: 'long'}).parse('يوم')).toBe(1);
205+
});
206+
197207
describe('round trips', function () {
198-
fc.configureGlobal({numRuns: 200});
208+
fc.configureGlobal({numRuns: 2000});
199209
// Locales have to include: 'de-DE', 'ar-EG', 'fr-FR' and possibly others
200210
// But for the moment they are not properly supported
201211
const localesArb = fc.constantFrom(...locales);
@@ -301,6 +311,78 @@ describe('NumberParser', function () {
301311
const formattedOnce = formatter.format(1);
302312
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
303313
});
314+
it('should handle small numbers', () => {
315+
let locale = 'ar-AE';
316+
let options = {
317+
style: 'decimal',
318+
minimumIntegerDigits: 4,
319+
maximumSignificantDigits: 1
320+
};
321+
const formatter = new Intl.NumberFormat(locale, options);
322+
const parser = new NumberParser(locale, options);
323+
const formattedOnce = formatter.format(2.220446049250313e-16);
324+
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
325+
});
326+
it('should handle currency small numbers', () => {
327+
let locale = 'ar-AE-u-nu-latn';
328+
let options = {
329+
style: 'currency',
330+
currency: 'USD'
331+
};
332+
const formatter = new Intl.NumberFormat(locale, options);
333+
const parser = new NumberParser(locale, options);
334+
const formattedOnce = formatter.format(2.220446049250313e-16);
335+
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
336+
});
337+
it('should handle hanidec small numbers', () => {
338+
let locale = 'ar-AE-u-nu-hanidec';
339+
let options = {
340+
style: 'decimal'
341+
};
342+
const formatter = new Intl.NumberFormat(locale, options);
343+
const parser = new NumberParser(locale, options);
344+
const formattedOnce = formatter.format(2.220446049250313e-16);
345+
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
346+
});
347+
it('should handle beng with minimum integer digits', () => {
348+
let locale = 'ar-AE-u-nu-beng';
349+
let options = {
350+
style: 'decimal',
351+
minimumIntegerDigits: 4,
352+
maximumFractionDigits: 0
353+
};
354+
const formatter = new Intl.NumberFormat(locale, options);
355+
const parser = new NumberParser(locale, options);
356+
const formattedOnce = formatter.format(2.220446049250313e-16);
357+
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
358+
});
359+
it('should handle percent with minimum integer digits', () => {
360+
let locale = 'ar-AE-u-nu-latn';
361+
let options = {
362+
style: 'percent',
363+
minimumIntegerDigits: 4,
364+
minimumFractionDigits: 9,
365+
maximumSignificantDigits: 1,
366+
maximumFractionDigits: undefined
367+
};
368+
const formatter = new Intl.NumberFormat(locale, options);
369+
const parser = new NumberParser(locale, options);
370+
const formattedOnce = formatter.format(0.0095);
371+
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
372+
});
373+
it('should handle non-grouping in russian locale', () => {
374+
let locale = 'ru-RU';
375+
let options = {
376+
style: 'percent',
377+
useGrouping: false,
378+
minimumFractionDigits: undefined,
379+
maximumFractionDigits: undefined
380+
};
381+
const formatter = new Intl.NumberFormat(locale, options);
382+
const parser = new NumberParser(locale, options);
383+
const formattedOnce = formatter.format(2.220446049250313e-16);
384+
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
385+
});
304386
});
305387
});
306388

@@ -327,14 +409,21 @@ describe('NumberParser', function () {
327409
});
328410

329411
it('should support group characters', function () {
330-
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',')).toBe(true); // en-US-u-nu-arab uses commas as the decimal point character
331-
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',000')).toBe(false); // latin numerals cannot follow arab decimal point
412+
// starting with arabic decimal point
413+
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',')).toBe(true);
414+
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',000')).toBe(true);
415+
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('000,000')).toBe(true);
332416
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1,000')).toBe(true);
333417
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000')).toBe(true);
334418
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1,000,000')).toBe(true);
335419
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000,000')).toBe(true);
336420
});
337421

422+
it('should return false for invalid grouping', function () {
423+
expect(new NumberParser('en-US', {useGrouping: false}).isValidPartialNumber('1234,7')).toBe(false);
424+
expect(new NumberParser('de-DE', {useGrouping: false}).isValidPartialNumber('1234.7')).toBe(false);
425+
});
426+
338427
it('should reject random characters', function () {
339428
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('g')).toBe(false);
340429
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1abc')).toBe(false);

packages/@react-spectrum/numberfield/test/NumberField.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,6 +2046,19 @@ describe('NumberField', function () {
20462046
expect(textField).toHaveAttribute('value', formatter.format(21));
20472047
});
20482048

2049+
it('should maintain original parser and formatting when restoring a previous value', async () => {
2050+
let {textField} = renderNumberField({onChange: onChangeSpy, defaultValue: 10});
2051+
expect(textField).toHaveAttribute('value', '10');
2052+
2053+
await user.tab();
2054+
await user.clear(textField);
2055+
await user.keyboard(',123');
2056+
act(() => {textField.blur();});
2057+
expect(textField).toHaveAttribute('value', '123');
2058+
expect(onChangeSpy).toHaveBeenCalledTimes(1);
2059+
expect(onChangeSpy).toHaveBeenCalledWith(123);
2060+
});
2061+
20492062
describe('beforeinput', () => {
20502063
let getTargetRanges = InputEvent.prototype.getTargetRanges;
20512064
beforeEach(() => {

packages/react-aria-components/stories/NumberField.stories.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {Button, FieldError, Group, Input, Label, NumberField, NumberFieldProps} from 'react-aria-components';
13+
import {Button, FieldError, Group, I18nProvider, Input, Label, NumberField, NumberFieldProps} from 'react-aria-components';
1414
import {Meta, StoryObj} from '@storybook/react';
1515
import React, {useState} from 'react';
1616
import './styles.css';
@@ -72,3 +72,23 @@ export const NumberFieldControlledExample = {
7272
<NumberFieldControlled {...args} />
7373
)
7474
};
75+
76+
export const ArabicNumberFieldExample = {
77+
args: {
78+
defaultValue: 0,
79+
formatOptions: {style: 'unit', unit: 'day', unitDisplay: 'long'}
80+
},
81+
render: (args) => (
82+
<I18nProvider locale="ar-AE">
83+
<NumberField {...args} validate={(v) => (v & 1 ? 'Invalid value' : null)}>
84+
<Label>Test</Label>
85+
<Group style={{display: 'flex'}}>
86+
<Button slot="decrement">-</Button>
87+
<Input />
88+
<Button slot="increment">+</Button>
89+
</Group>
90+
<FieldError />
91+
</NumberField>
92+
</I18nProvider>
93+
)
94+
};

0 commit comments

Comments
 (0)