From 5b57af9cd0c338bcba8581afbb254e91e38434bb Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 16 Jul 2025 15:33:25 +1000 Subject: [PATCH 01/12] fix: NumberParsing --- .../number/src/NumberParser.ts | 60 ++++++++++++++++-- .../number/test/NumberParser.test.js | 62 ++++++++++++++++++- .../numberfield/test/NumberField.test.js | 15 ++++- 3 files changed, 129 insertions(+), 8 deletions(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index cc0ab0b43b6..12b1c104e18 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -19,6 +19,7 @@ interface Symbols { group?: string, literals: RegExp, numeral: RegExp, + numerals: string[], index: (v: string) => string } @@ -197,6 +198,7 @@ class NumberParserImpl { // Remove literals and whitespace, which are allowed anywhere in the string value = value.replace(this.symbols.literals, ''); + // Replace the ASCII minus sign with the minus sign used in the current locale // so that both are allowed in case the user's keyboard doesn't have the locale's minus sign. if (this.symbols.minusSign) { @@ -207,14 +209,18 @@ class NumberParserImpl { // instead they use the , (44) character or apparently the (1548) character. if (this.options.numberingSystem === 'arab') { if (this.symbols.decimal) { - value = value.replace(',', this.symbols.decimal); - value = value.replace(String.fromCharCode(1548), this.symbols.decimal); + value = replaceAll(value, ',', this.symbols.decimal); + value = replaceAll(value, String.fromCharCode(1548), this.symbols.decimal); } if (this.symbols.group) { value = replaceAll(value, '.', this.symbols.group); } } + if (this.symbols.group && value.includes("'")) { + value = replaceAll(value, "'", this.symbols.group); + } + // fr-FR group character is narrow non-breaking space, char code 8239 (U+202F), but that's not a key on the french keyboard, // so allow space and non-breaking space as a group char as well if (this.options.locale === 'fr-FR' && this.symbols.group) { @@ -222,6 +228,41 @@ class NumberParserImpl { value = replaceAll(value, /\u00A0/g, this.symbols.group); } + if (this.symbols.decimal + && this.symbols.group + && [...value.matchAll(new RegExp(escapeRegex(this.symbols.decimal), 'g'))].length > 1 + && [...value.matchAll(new RegExp(escapeRegex(this.symbols.group), 'g'))].length <= 1) { + value = swapCharacters(value, this.symbols.decimal, this.symbols.group); + } + + let decimalIndex = value.indexOf(this.symbols.decimal!); + let groupIndex = value.indexOf(this.symbols.group!); + if (this.symbols.decimal && this.symbols.group && decimalIndex > -1 && groupIndex > -1 && decimalIndex < groupIndex) { + value = swapCharacters(value, this.symbols.decimal, this.symbols.group); + } + + // in the value, for any non-digits and not the plus/minus sign, + // if there is only one of that character and its index in the string is 0 or it's only preceeded by this locale's "0" character, + // then we know it's a decimal character and we can replace it with the decimal character for the locale we're currently trying + let temp = value; + if (this.symbols.minusSign) { + temp = replaceAll(temp, this.symbols.minusSign, ''); + temp = replaceAll(temp, '\u2212', ''); + } + if (this.symbols.plusSign) { + temp = replaceAll(temp, this.symbols.plusSign, ''); + } + temp = replaceAll(temp, new RegExp(`^${escapeRegex(this.symbols.numerals[0])}+`, 'g'), ''); + let nonDigits = new Set(replaceAll(temp, this.symbols.numeral, '').split('')); + if (temp.length > 0 && nonDigits.has(temp[0])) { + let count = temp.match(new RegExp(escapeRegex(temp[0]), 'g'))?.length ?? 0; + // Only swap if there's exactly one group separator and it's at the beginning (after removing signs and leading zeros) + // AND there are no other decimal separators in the string + if (count === 1 && temp[0] === this.symbols.group && !value.includes(this.symbols.decimal!)) { + value = swapCharacters(value, temp[0], this.symbols.decimal!); + } + } + return value; } @@ -305,9 +346,10 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I let pluralPartsLiterals = pluralParts.flatMap(p => p.filter(p => !nonLiteralParts.has(p.type)).map(p => escapeRegex(p.value))); let sortedLiterals = [...new Set([...allPartsLiterals, ...pluralPartsLiterals])].sort((a, b) => b.length - a.length); + // Match both whitespace and formatting characters let literals = sortedLiterals.length === 0 ? - new RegExp('[\\p{White_Space}]', 'gu') : - new RegExp(`${sortedLiterals.join('|')}|[\\p{White_Space}]`, 'gu'); + new RegExp('\\p{White_Space}|\\p{Cf}', 'gu') : + new RegExp(`${sortedLiterals.join('|')}|\\p{White_Space}|\\p{Cf}`, 'gu'); // These are for replacing non-latn characters with the latn equivalent let numerals = [...new Intl.NumberFormat(intlOptions.locale, {useGrouping: false}).format(9876543210)].reverse(); @@ -315,7 +357,15 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I let numeral = new RegExp(`[${numerals.join('')}]`, 'g'); let index = d => String(indexes.get(d)); - return {minusSign, plusSign, decimal, group, literals, numeral, index}; + return {minusSign, plusSign, decimal, group, literals, numeral, numerals, index}; +} + +function swapCharacters(str: string, char1: string, char2: string) { + const tempChar = '_TEMP_'; + let result = str.replaceAll(char1, tempChar); + result = result.replaceAll(char2, char1); + result = result.replaceAll(tempChar, char2); + return result; } function replaceAll(str: string, find: string | RegExp, replace: string) { diff --git a/packages/@internationalized/number/test/NumberParser.test.js b/packages/@internationalized/number/test/NumberParser.test.js index 4c9ef627de0..36fae15eac9 100644 --- a/packages/@internationalized/number/test/NumberParser.test.js +++ b/packages/@internationalized/number/test/NumberParser.test.js @@ -38,6 +38,11 @@ describe('NumberParser', function () { expect(new NumberParser('en-US', {style: 'decimal'}).parse('-1,000,000')).toBe(-1000000); }); + it('should support accidentally using a group character as a decimal character', function () { + expect(new NumberParser('en-US', {style: 'decimal'}).parse('1.000,00')).toBe(1000); + expect(new NumberParser('en-US', {style: 'decimal'}).parse('1.000.000,00')).toBe(1000000); + }); + it('should support signDisplay', function () { expect(new NumberParser('en-US', {style: 'decimal'}).parse('+10')).toBe(10); expect(new NumberParser('en-US', {style: 'decimal', signDisplay: 'always'}).parse('+10')).toBe(10); @@ -188,6 +193,12 @@ describe('NumberParser', function () { }); }); + it('should parse a swiss currency number', () => { + expect(new NumberParser('de-CH', {style: 'currency', currency: 'CHF'}).parse('CHF 1’000.00')).toBe(1000); + expect(new NumberParser('de-CH', {style: 'currency', currency: 'CHF'}).parse("CHF 1'000.00")).toBe(1000); + expect(new NumberParser('de-CH', {style: 'currency', currency: 'CHF'}).parse("CHF 1'000.00")).toBe(1000); + }); + describe('round trips', function () { fc.configureGlobal({numRuns: 200}); // Locales have to include: 'de-DE', 'ar-EG', 'fr-FR' and possibly others @@ -295,6 +306,51 @@ describe('NumberParser', function () { const formattedOnce = formatter.format(1); expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); }); + it('should handle small numbers', () => { + let locale = 'ar-AE'; + let options = { + style: 'decimal', + minimumIntegerDigits: 4, + maximumSignificantDigits: 1 + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); + it('should handle currency', () => { + let locale = 'ar-AE-u-nu-latn'; + let options = { + style: 'currency', + currency: 'USD' + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); + it('should handle hanidec', () => { + let locale = 'ar-AE-u-nu-hanidec'; + let options = { + style: 'decimal' + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); + it('should handle beng', () => { + let locale = 'ar-AE-u-nu-beng'; + let options = { + style: 'decimal', + minimumIntegerDigits: 4, + maximumFractionDigits: 0 + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); }); }); @@ -321,8 +377,10 @@ describe('NumberParser', function () { }); it('should support group characters', function () { - expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',')).toBe(true); // en-US-u-nu-arab uses commas as the decimal point character - expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',000')).toBe(false); // latin numerals cannot follow arab decimal point + // starting with arabic decimal point + expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',')).toBe(true); + expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',000')).toBe(true); + expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('000,000')).toBe(true); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1,000')).toBe(true); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000')).toBe(true); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1,000,000')).toBe(true); diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js index 73f1c306fff..15f159cea4d 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -2042,6 +2042,19 @@ describe('NumberField', function () { expect(textField).toHaveAttribute('value', formatter.format(21)); }); + it('should maintain original parser and formatted when restoring a previous value', async () => { + let {textField} = renderNumberField({onChange: onChangeSpy, defaultValue: 10}); + expect(textField).toHaveAttribute('value', '10'); + + userEvent.tab(); + userEvent.clear(textField); + await user.keyboard(',123'); + act(() => {textField.blur();}); + expect(textField).toHaveAttribute('value', '0.123'); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenCalledWith(0.123); + }); + describe('beforeinput', () => { let getTargetRanges = InputEvent.prototype.getTargetRanges; beforeEach(() => { @@ -2314,7 +2327,7 @@ describe('NumberField', function () { it('resets to defaultValue when submitting form action', async () => { function Test() { const [value, formAction] = React.useActionState(() => 33, 22); - + return (
From 95874efaa37cd9afdc15ea47c263b6f63c8d6203 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 16 Jul 2025 15:37:05 +1000 Subject: [PATCH 02/12] fix test usage --- packages/@react-spectrum/numberfield/test/NumberField.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js index 15f159cea4d..c83df0923d5 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -2046,8 +2046,8 @@ describe('NumberField', function () { let {textField} = renderNumberField({onChange: onChangeSpy, defaultValue: 10}); expect(textField).toHaveAttribute('value', '10'); - userEvent.tab(); - userEvent.clear(textField); + await user.tab(); + await user.clear(textField); await user.keyboard(',123'); act(() => {textField.blur();}); expect(textField).toHaveAttribute('value', '0.123'); From 6160d5f47dd9fedfe49e3424ddd1da1d47072d6a Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 17 Jul 2025 16:46:26 +1000 Subject: [PATCH 03/12] fuzzier matching for numbers --- .../number/src/NumberParser.ts | 60 ++++++++++++++++++- .../test/NumberField.test.js | 43 +++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index 12b1c104e18..d1c0f4ff9ed 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -195,8 +195,24 @@ class NumberParserImpl { } sanitize(value: string) { - // Remove literals and whitespace, which are allowed anywhere in the string - value = value.replace(this.symbols.literals, ''); + // Do our best to preserve the number and its possible group and decimal symbols, this includes the sign as well + let preservedInsideNumber = value.match(new RegExp(`([${this.symbols.numerals.join('')}].*[${this.symbols.numerals.join('')}])`)); + if (preservedInsideNumber) { + // If we found a number, replace literals everywhere except inside the number + let beforeNumber = value.substring(0, preservedInsideNumber.index!); + let afterNumber = value.substring(preservedInsideNumber.index! + preservedInsideNumber[0].length); + let insideNumber = preservedInsideNumber[0]; + + // Replace literals in the parts outside the number + beforeNumber = beforeNumber.replace(this.symbols.literals, ''); + afterNumber = afterNumber.replace(this.symbols.literals, ''); + + // Reconstruct the value with literals removed from outside the number + value = beforeNumber + insideNumber + afterNumber; + } else { + // If no number found, replace literals everywhere + value = value.replace(this.symbols.literals, ''); + } // Replace the ASCII minus sign with the minus sign used in the current locale @@ -217,6 +233,7 @@ class NumberParserImpl { } } + // Some locales, such as swiss when using currency, use a single quote as a group character if (this.symbols.group && value.includes("'")) { value = replaceAll(value, "'", this.symbols.group); } @@ -228,6 +245,7 @@ class NumberParserImpl { value = replaceAll(value, /\u00A0/g, this.symbols.group); } + // If there are multiple decimal separators and only one group separator, swap them if (this.symbols.decimal && this.symbols.group && [...value.matchAll(new RegExp(escapeRegex(this.symbols.decimal), 'g'))].length > 1 @@ -235,6 +253,7 @@ class NumberParserImpl { value = swapCharacters(value, this.symbols.decimal, this.symbols.group); } + // If the decimal separator is before the group separator, swap them let decimalIndex = value.indexOf(this.symbols.decimal!); let groupIndex = value.indexOf(this.symbols.group!); if (this.symbols.decimal && this.symbols.group && decimalIndex > -1 && groupIndex > -1 && decimalIndex < groupIndex) { @@ -242,7 +261,7 @@ class NumberParserImpl { } // in the value, for any non-digits and not the plus/minus sign, - // if there is only one of that character and its index in the string is 0 or it's only preceeded by this locale's "0" character, + // if there is only one of that character and its index in the string is 0 or it's only preceeded by this numbering system's "0" character, // then we know it's a decimal character and we can replace it with the decimal character for the locale we're currently trying let temp = value; if (this.symbols.minusSign) { @@ -263,6 +282,38 @@ class NumberParserImpl { } } + // This is to fuzzy match group and decimal symbols from a different formatting, we can only do it if there are 2 non-digits, otherwise it's too ambiguous + let areOnlyGroupAndDecimalSymbols = [...nonDigits].every(char => allPossibleGroupAndDecimalSymbols.has(char)); + let oneSymbolNotMatching = ( + nonDigits.size === 2 + && this.symbols.group + && this.symbols.decimal + && (!nonDigits.has(this.symbols.group!) || !nonDigits.has(this.symbols.decimal!)) + ); + let bothSymbolsNotMatching = ( + nonDigits.size === 2 + && this.symbols.group + && this.symbols.decimal + && !nonDigits.has(this.symbols.group!) && !nonDigits.has(this.symbols.decimal!) + ); + if (areOnlyGroupAndDecimalSymbols && (oneSymbolNotMatching || bothSymbolsNotMatching)) { + // Try to determine which of the nonDigits is the group and which is the decimal + // Whichever of the nonDigits is first in the string is the group. + // If there are more than one of a nonDigit, then that one is the group. + let [firstChar, secondChar] = [...nonDigits]; + if (value.indexOf(firstChar) < value.indexOf(secondChar)) { + value = replaceAll(value, firstChar, '__GROUP__'); + value = replaceAll(value, secondChar, '__DECIMAL__'); + value = replaceAll(value, '__GROUP__', this.symbols.group!); + value = replaceAll(value, '__DECIMAL__', this.symbols.decimal!); + } else { + value = replaceAll(value, secondChar, '__GROUP__'); + value = replaceAll(value, firstChar, '__DECIMAL__'); + value = replaceAll(value, '__GROUP__', this.symbols.group!); + value = replaceAll(value, '__DECIMAL__', this.symbols.decimal!); + } + } + return value; } @@ -302,6 +353,9 @@ class NumberParserImpl { const nonLiteralParts = new Set(['decimal', 'fraction', 'integer', 'minusSign', 'plusSign', 'group']); +// This list is a best guess at the moment +const allPossibleGroupAndDecimalSymbols = new Set(['.', ',', ' ', String.fromCharCode(1548), '\u00A0', "'"]); + // This list is derived from https://www.unicode.org/cldr/charts/43/supplemental/language_plural_rules.html#comparison and includes // all unique numbers which we need to check in order to determine all the plural forms for a given locale. // See: https://github.com/adobe/react-spectrum/pull/5134/files#r1337037855 for used script diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 14c3e0db770..5f27b048b07 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -183,4 +183,47 @@ describe('NumberField', () => { expect(input).not.toHaveAttribute('aria-describedby'); expect(numberfield).not.toHaveAttribute('data-invalid'); }); + + it('supports pasting value in another numbering system', async () => { + let {getByRole, rerender} = render(); + let input = getByRole('textbox'); + await user.tab(); + act(() => { + input.setSelectionRange(0, input.value.length); + }); + await user.paste('3.000.000,25'); + await user.keyboard('{Enter}'); + expect(input).toHaveValue('3,000,000.25'); + + act(() => { + input.setSelectionRange(0, input.value.length); + }); + await user.paste('3 000 000,25'); + await user.keyboard('{Enter}'); + expect(input).toHaveValue('3,000,000.25'); + + rerender(); + + act(() => { + input.setSelectionRange(0, input.value.length); + }); + await user.paste('3 000 000,256789'); + await user.keyboard('{Enter}'); + expect(input).toHaveValue('$3,000,000.26'); + + act(() => { + input.setSelectionRange(0, input.value.length); + }); + await user.paste('1,000'); + await user.keyboard('{Enter}'); + expect(input).toHaveValue('$1,000.00', 'Ambiguous value should be parsed using the current locale'); + + act(() => { + input.setSelectionRange(0, input.value.length); + }); + + await user.paste('1.000'); + await user.keyboard('{Enter}'); + expect(input).toHaveValue('$1.00', 'Ambiguous value should be parsed using the current locale'); + }); }); From fb0aa081d6121a364cc8780dc3240ff59f9dd65b Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 17 Jul 2025 19:10:46 +1000 Subject: [PATCH 04/12] fix ambiguous case with leading zeroes --- packages/@internationalized/number/src/NumberParser.ts | 10 +++++++++- .../numberfield/test/NumberField.test.js | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index d1c0f4ff9ed..3ecfc32ac1a 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -278,7 +278,15 @@ class NumberParserImpl { // Only swap if there's exactly one group separator and it's at the beginning (after removing signs and leading zeros) // AND there are no other decimal separators in the string if (count === 1 && temp[0] === this.symbols.group && !value.includes(this.symbols.decimal!)) { - value = swapCharacters(value, temp[0], this.symbols.decimal!); + if (this.options.minimumIntegerDigits > 0) { + let index = value.indexOf(temp[0]); + + // Check the ambiguous case where the user is typing 0,001 for 1 because + // the minimum integer causes a bunch of leading zeros. + if (index < this.options.minimumIntegerDigits) { + value = swapCharacters(value, temp[0], this.symbols.decimal!); + } + } } } diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js index c83df0923d5..1d6b0f69099 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -2042,7 +2042,7 @@ describe('NumberField', function () { expect(textField).toHaveAttribute('value', formatter.format(21)); }); - it('should maintain original parser and formatted when restoring a previous value', async () => { + it('should maintain original parser and formatting when restoring a previous value', async () => { let {textField} = renderNumberField({onChange: onChangeSpy, defaultValue: 10}); expect(textField).toHaveAttribute('value', '10'); From 3583f8c0e5273df8033a0967cfdbfc4d007886f4 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 18 Jul 2025 18:05:28 +1000 Subject: [PATCH 05/12] fix last breaking tests --- .../number/src/NumberParser.ts | 6 +++-- .../number/test/NumberParser.test.js | 22 +++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index 3ecfc32ac1a..8666f8af63a 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -278,14 +278,16 @@ class NumberParserImpl { // Only swap if there's exactly one group separator and it's at the beginning (after removing signs and leading zeros) // AND there are no other decimal separators in the string if (count === 1 && temp[0] === this.symbols.group && !value.includes(this.symbols.decimal!)) { - if (this.options.minimumIntegerDigits > 0) { + if (this.options.minimumIntegerDigits > 1) { let index = value.indexOf(temp[0]); // Check the ambiguous case where the user is typing 0,001 for 1 because // the minimum integer causes a bunch of leading zeros. - if (index < this.options.minimumIntegerDigits) { + if (index > this.options.minimumIntegerDigits) { value = swapCharacters(value, temp[0], this.symbols.decimal!); } + } else { + value = swapCharacters(value, temp[0], this.symbols.decimal!); } } } diff --git a/packages/@internationalized/number/test/NumberParser.test.js b/packages/@internationalized/number/test/NumberParser.test.js index 36fae15eac9..da03f288ebd 100644 --- a/packages/@internationalized/number/test/NumberParser.test.js +++ b/packages/@internationalized/number/test/NumberParser.test.js @@ -200,7 +200,7 @@ describe('NumberParser', function () { }); describe('round trips', function () { - fc.configureGlobal({numRuns: 200}); + fc.configureGlobal({numRuns: 2000}); // Locales have to include: 'de-DE', 'ar-EG', 'fr-FR' and possibly others // But for the moment they are not properly supported const localesArb = fc.constantFrom(...locales); @@ -318,7 +318,7 @@ describe('NumberParser', function () { const formattedOnce = formatter.format(2.220446049250313e-16); expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); }); - it('should handle currency', () => { + it('should handle currency small numbers', () => { let locale = 'ar-AE-u-nu-latn'; let options = { style: 'currency', @@ -329,7 +329,7 @@ describe('NumberParser', function () { const formattedOnce = formatter.format(2.220446049250313e-16); expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); }); - it('should handle hanidec', () => { + it('should handle hanidec small numbers', () => { let locale = 'ar-AE-u-nu-hanidec'; let options = { style: 'decimal' @@ -339,7 +339,7 @@ describe('NumberParser', function () { const formattedOnce = formatter.format(2.220446049250313e-16); expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); }); - it('should handle beng', () => { + it('should handle beng with minimum integer digits', () => { let locale = 'ar-AE-u-nu-beng'; let options = { style: 'decimal', @@ -351,6 +351,20 @@ describe('NumberParser', function () { const formattedOnce = formatter.format(2.220446049250313e-16); expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); }); + it('should handle percent with minimum integer digits', () => { + let locale = 'ar-AE-u-nu-latn'; + let options = { + style: 'percent', + minimumIntegerDigits: 4, + minimumFractionDigits: 9, + maximumSignificantDigits: 1, + maximumFractionDigits: undefined + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(0.0095); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); }); }); From 9637ec7a3e3428878dd4cff046f9ddc7405ac83c Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 21 Jul 2025 15:02:49 +1000 Subject: [PATCH 06/12] fix case of formatted numbers with no numerals --- .../number/src/NumberParser.ts | 18 ++++++++++++++++-- .../number/test/NumberParser.test.js | 5 +++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index 8666f8af63a..63b5b50d632 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -20,7 +20,8 @@ interface Symbols { literals: RegExp, numeral: RegExp, numerals: string[], - index: (v: string) => string + index: (v: string) => string, + noNumeralUnits: Array<{unit: string, value: number}> } const CURRENCY_SIGN_REGEX = new RegExp('^.*\\(.*\\).*$'); @@ -195,6 +196,11 @@ class NumberParserImpl { } sanitize(value: string) { + // 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 + // return the known value for that case. + if (this.symbols.noNumeralUnits.length > 0 && this.symbols.noNumeralUnits.find(obj => obj.unit === value)) { + return this.symbols.noNumeralUnits.find(obj => obj.unit === value)!.value.toString(); + } // Do our best to preserve the number and its possible group and decimal symbols, this includes the sign as well let preservedInsideNumber = value.match(new RegExp(`([${this.symbols.numerals.join('')}].*[${this.symbols.numerals.join('')}])`)); if (preservedInsideNumber) { @@ -387,6 +393,14 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I let allParts = symbolFormatter.formatToParts(-10000.111); let posAllParts = symbolFormatter.formatToParts(10000.111); let pluralParts = pluralNumbers.map(n => symbolFormatter.formatToParts(n)); + // if the plural parts include a unit but no integer or fraction, then we need to add the unit to the special set + let noNumeralUnits = pluralParts.map((p, i) => { + let unit = p.find(p => p.type === 'unit'); + if (unit && !p.some(p => p.type === 'integer' || p.type === 'fraction')) { + return {unit: unit.value, value: pluralNumbers[i]}; + } + return null; + }).filter(p => !!p); let minusSign = allParts.find(p => p.type === 'minusSign')?.value ?? '-'; let plusSign = posAllParts.find(p => p.type === 'plusSign')?.value; @@ -421,7 +435,7 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I let numeral = new RegExp(`[${numerals.join('')}]`, 'g'); let index = d => String(indexes.get(d)); - return {minusSign, plusSign, decimal, group, literals, numeral, numerals, index}; + return {minusSign, plusSign, decimal, group, literals, numeral, numerals, index, noNumeralUnits}; } function swapCharacters(str: string, char1: string, char2: string) { diff --git a/packages/@internationalized/number/test/NumberParser.test.js b/packages/@internationalized/number/test/NumberParser.test.js index da03f288ebd..9590693454b 100644 --- a/packages/@internationalized/number/test/NumberParser.test.js +++ b/packages/@internationalized/number/test/NumberParser.test.js @@ -199,6 +199,11 @@ describe('NumberParser', function () { expect(new NumberParser('de-CH', {style: 'currency', currency: 'CHF'}).parse("CHF 1'000.00")).toBe(1000); }); + it('should parse arabic singular and dual counts', () => { + expect(new NumberParser('ar-AE', {style: 'unit', unit: 'day', unitDisplay: 'long'}).parse('يومان')).toBe(2); + expect(new NumberParser('ar-AE', {style: 'unit', unit: 'day', unitDisplay: 'long'}).parse('يوم')).toBe(1); + }); + describe('round trips', function () { fc.configureGlobal({numRuns: 2000}); // Locales have to include: 'de-DE', 'ar-EG', 'fr-FR' and possibly others From 8a312f3cc8a31e9d32f333575da502fba4625812 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 21 Jul 2025 15:17:28 +1000 Subject: [PATCH 07/12] fix cases of numbers with no numerals --- .../stories/NumberField.stories.tsx | 22 ++++++++++++++- .../test/NumberField.test.js | 28 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/stories/NumberField.stories.tsx b/packages/react-aria-components/stories/NumberField.stories.tsx index d67cd086b65..7cd4ce47889 100644 --- a/packages/react-aria-components/stories/NumberField.stories.tsx +++ b/packages/react-aria-components/stories/NumberField.stories.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {Button, FieldError, Group, Input, Label, NumberField, NumberFieldProps} from 'react-aria-components'; +import {Button, FieldError, Group, I18nProvider, Input, Label, NumberField, NumberFieldProps} from 'react-aria-components'; import {Meta, StoryObj} from '@storybook/react'; import React, {useState} from 'react'; @@ -71,3 +71,23 @@ export const NumberFieldControlledExample = { ) }; + +export const ArabicNumberFieldExample = { + args: { + defaultValue: 0, + formatOptions: {style: 'unit', unit: 'day', unitDisplay: 'long'} + }, + render: (args) => ( + + (v & 1 ? 'Invalid value' : null)}> + + + + + + + + + + ) +}; diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 5f27b048b07..45eab3b3cba 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -11,7 +11,7 @@ */ import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; -import {Button, FieldError, Group, Input, Label, NumberField, NumberFieldContext, Text} from '../'; +import {Button, FieldError, Group, I18nProvider, Input, Label, NumberField, NumberFieldContext, Text} from '../'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -226,4 +226,30 @@ describe('NumberField', () => { await user.keyboard('{Enter}'); expect(input).toHaveValue('$1.00', 'Ambiguous value should be parsed using the current locale'); }); + + it('should support arabic singular and dual counts', async () => { + let onChange = jest.fn(); + let {getByRole} = render( + + + + + + + + + + + + ); + let input = getByRole('textbox'); + await user.tab(); + await user.keyboard('{ArrowUp}'); + expect(onChange).toHaveBeenLastCalledWith(1); + expect(input).toHaveValue('يوم'); + + await user.keyboard('{ArrowUp}'); + expect(input).toHaveValue('يومان'); + expect(onChange).toHaveBeenLastCalledWith(2); + }); }); From 88970308cec99ed37565542e42555be7fa2f8e66 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 21 Jul 2025 17:31:50 +1000 Subject: [PATCH 08/12] remove ambiguous case but allow for numbers to start with group characters --- .../number/src/NumberParser.ts | 23 ------------------- .../numberfield/test/NumberField.test.js | 4 ++-- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index 63b5b50d632..a455fc73c6b 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -279,24 +279,6 @@ class NumberParserImpl { } temp = replaceAll(temp, new RegExp(`^${escapeRegex(this.symbols.numerals[0])}+`, 'g'), ''); let nonDigits = new Set(replaceAll(temp, this.symbols.numeral, '').split('')); - if (temp.length > 0 && nonDigits.has(temp[0])) { - let count = temp.match(new RegExp(escapeRegex(temp[0]), 'g'))?.length ?? 0; - // Only swap if there's exactly one group separator and it's at the beginning (after removing signs and leading zeros) - // AND there are no other decimal separators in the string - if (count === 1 && temp[0] === this.symbols.group && !value.includes(this.symbols.decimal!)) { - if (this.options.minimumIntegerDigits > 1) { - let index = value.indexOf(temp[0]); - - // Check the ambiguous case where the user is typing 0,001 for 1 because - // the minimum integer causes a bunch of leading zeros. - if (index > this.options.minimumIntegerDigits) { - value = swapCharacters(value, temp[0], this.symbols.decimal!); - } - } else { - value = swapCharacters(value, temp[0], this.symbols.decimal!); - } - } - } // This is to fuzzy match group and decimal symbols from a different formatting, we can only do it if there are 2 non-digits, otherwise it's too ambiguous let areOnlyGroupAndDecimalSymbols = [...nonDigits].every(char => allPossibleGroupAndDecimalSymbols.has(char)); @@ -343,11 +325,6 @@ class NumberParserImpl { value = value.slice(this.symbols.plusSign.length); } - // Numbers cannot start with a group separator - if (this.symbols.group && value.startsWith(this.symbols.group)) { - return false; - } - // Numbers that can't have any decimal values fail if a decimal character is typed if (this.symbols.decimal && value.indexOf(this.symbols.decimal) > -1 && this.options.maximumFractionDigits === 0) { return false; diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js index 1d6b0f69099..6dfb09634d2 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -2050,9 +2050,9 @@ describe('NumberField', function () { await user.clear(textField); await user.keyboard(',123'); act(() => {textField.blur();}); - expect(textField).toHaveAttribute('value', '0.123'); + expect(textField).toHaveAttribute('value', '123'); expect(onChangeSpy).toHaveBeenCalledTimes(1); - expect(onChangeSpy).toHaveBeenCalledWith(0.123); + expect(onChangeSpy).toHaveBeenCalledWith(123); }); describe('beforeinput', () => { From f8267616761742fe96a0db53f0cd20f8f36682f9 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 21 Jul 2025 17:36:35 +1000 Subject: [PATCH 09/12] explanation --- packages/@internationalized/number/src/NumberParser.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index a455fc73c6b..e5e9efd7e46 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -268,7 +268,8 @@ class NumberParserImpl { // in the value, for any non-digits and not the plus/minus sign, // if there is only one of that character and its index in the string is 0 or it's only preceeded by this numbering system's "0" character, - // then we know it's a decimal character and we can replace it with the decimal character for the locale we're currently trying + // then we could try to guess that it's a decimal character and replace it, but it's too ambiguous, a user may be deleting 1,024 -> ,024 and + // we don't want to change 24 into .024 let temp = value; if (this.symbols.minusSign) { temp = replaceAll(temp, this.symbols.minusSign, ''); From f459439dc10064f422c63abfa421d60d715d3a84 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 27 Oct 2025 10:22:59 +1100 Subject: [PATCH 10/12] handle ambiguous group vs decimal case --- .../number/src/NumberParser.ts | 33 +++++++++++-------- .../number/test/NumberParser.test.js | 23 +++++++++++++ .../test/NumberField.test.js | 30 +++++++++++++++++ 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index 39ad757f751..10ad0d60195 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -132,13 +132,17 @@ class NumberParserImpl { } parse(value: string) { + let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; // 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' let fullySanitizedValue = this.sanitize(value); - if (this.symbols.group) { - // Remove group characters, and replace decimal points and numerals with ASCII values. - fullySanitizedValue = replaceAll(fullySanitizedValue, this.symbols.group, ''); + // Return NaN if there is a group symbol but useGrouping is false + if (!isGroupSymbolAllowed && this.symbols.group && fullySanitizedValue.includes(this.symbols.group)) { + return NaN; + } else if (this.symbols.group) { + fullySanitizedValue = fullySanitizedValue.replaceAll(this.symbols.group!, ''); } + if (this.symbols.decimal) { fullySanitizedValue = fullySanitizedValue.replace(this.symbols.decimal!, '.'); } @@ -191,11 +195,11 @@ class NumberParserImpl { if (this.options.currencySign === 'accounting' && CURRENCY_SIGN_REGEX.test(value)) { newValue = -1 * newValue; } - return newValue; } sanitize(value: string) { + let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; // 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 // return the known value for that case. if (this.symbols.noNumeralUnits.length > 0 && this.symbols.noNumeralUnits.find(obj => obj.unit === value)) { @@ -220,7 +224,6 @@ class NumberParserImpl { value = value.replace(this.symbols.literals, ''); } - // Replace the ASCII minus sign with the minus sign used in the current locale // so that both are allowed in case the user's keyboard doesn't have the locale's minus sign. if (this.symbols.minusSign) { @@ -234,27 +237,27 @@ class NumberParserImpl { value = replaceAll(value, ',', this.symbols.decimal); value = replaceAll(value, String.fromCharCode(1548), this.symbols.decimal); } - if (this.symbols.group) { + if (this.symbols.group && isGroupSymbolAllowed) { value = replaceAll(value, '.', this.symbols.group); } } // In some locale styles, such as swiss currency, the group character can be a special single quote // that keyboards don't typically have. This expands the character to include the easier to type single quote. - if (this.symbols.group === '’' && value.includes("'")) { + if (this.symbols.group === '’' && value.includes("'") && isGroupSymbolAllowed) { value = replaceAll(value, "'", this.symbols.group); } // fr-FR group character is narrow non-breaking space, char code 8239 (U+202F), but that's not a key on the french keyboard, // so allow space and non-breaking space as a group char as well - if (this.options.locale === 'fr-FR' && this.symbols.group) { + if (this.options.locale === 'fr-FR' && this.symbols.group && isGroupSymbolAllowed) { value = replaceAll(value, ' ', this.symbols.group); value = replaceAll(value, /\u00A0/g, this.symbols.group); } // If there are multiple decimal separators and only one group separator, swap them if (this.symbols.decimal - && this.symbols.group + && (this.symbols.group && isGroupSymbolAllowed) && [...value.matchAll(new RegExp(escapeRegex(this.symbols.decimal), 'g'))].length > 1 && [...value.matchAll(new RegExp(escapeRegex(this.symbols.group), 'g'))].length <= 1) { value = swapCharacters(value, this.symbols.decimal, this.symbols.group); @@ -263,7 +266,7 @@ class NumberParserImpl { // If the decimal separator is before the group separator, swap them let decimalIndex = value.indexOf(this.symbols.decimal!); let groupIndex = value.indexOf(this.symbols.group!); - if (this.symbols.decimal && this.symbols.group && decimalIndex > -1 && groupIndex > -1 && decimalIndex < groupIndex) { + if (this.symbols.decimal && (this.symbols.group && isGroupSymbolAllowed) && decimalIndex > -1 && groupIndex > -1 && decimalIndex < groupIndex) { value = swapCharacters(value, this.symbols.decimal, this.symbols.group); } @@ -286,13 +289,13 @@ class NumberParserImpl { let areOnlyGroupAndDecimalSymbols = [...nonDigits].every(char => allPossibleGroupAndDecimalSymbols.has(char)); let oneSymbolNotMatching = ( nonDigits.size === 2 - && this.symbols.group + && (this.symbols.group && isGroupSymbolAllowed) && this.symbols.decimal && (!nonDigits.has(this.symbols.group!) || !nonDigits.has(this.symbols.decimal!)) ); let bothSymbolsNotMatching = ( nonDigits.size === 2 - && this.symbols.group + && (this.symbols.group && isGroupSymbolAllowed) && this.symbols.decimal && !nonDigits.has(this.symbols.group!) && !nonDigits.has(this.symbols.decimal!) ); @@ -318,6 +321,7 @@ class NumberParserImpl { } isValidPartialNumber(value: string, minValue: number = -Infinity, maxValue: number = Infinity): boolean { + let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; value = this.sanitize(value); // Remove minus or plus sign, which must be at the start of the string. @@ -333,7 +337,7 @@ class NumberParserImpl { } // Remove numerals, groups, and decimals - if (this.symbols.group) { + if (this.symbols.group && isGroupSymbolAllowed) { value = replaceAll(value, this.symbols.group, ''); } value = value.replace(this.symbols.numeral, ''); @@ -366,7 +370,8 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I maximumSignificantDigits: 21, roundingIncrement: 1, roundingPriority: 'auto', - roundingMode: 'halfExpand' + roundingMode: 'halfExpand', + useGrouping: true }); // Note: some locale's don't add a group symbol until there is a ten thousands place let allParts = symbolFormatter.formatToParts(-10000.111); diff --git a/packages/@internationalized/number/test/NumberParser.test.js b/packages/@internationalized/number/test/NumberParser.test.js index 9590693454b..bc14876ef6c 100644 --- a/packages/@internationalized/number/test/NumberParser.test.js +++ b/packages/@internationalized/number/test/NumberParser.test.js @@ -61,6 +61,11 @@ describe('NumberParser', function () { expect(new NumberParser('en-US', {style: 'decimal'}).parse('1abc')).toBe(NaN); }); + it('should return NaN for invalid grouping', function () { + expect(new NumberParser('en-US', {useGrouping: false}).parse('1234,7')).toBeNaN(); + expect(new NumberParser('de-DE', {useGrouping: false}).parse('1234.7')).toBeNaN(); + }); + describe('currency', function () { it('should parse without the currency symbol', function () { expect(new NumberParser('en-US', {currency: 'USD', style: 'currency'}).parse('10.50')).toBe(10.5); @@ -370,6 +375,19 @@ describe('NumberParser', function () { const formattedOnce = formatter.format(0.0095); expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); }); + it('should handle non-grouping in russian locale', () => { + let locale = 'ru-RU'; + let options = { + style: 'percent', + useGrouping: false, + minimumFractionDigits: undefined, + maximumFractionDigits: undefined + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); }); }); @@ -406,6 +424,11 @@ describe('NumberParser', function () { expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000,000')).toBe(true); }); + it('should return false for invalid grouping', function () { + expect(new NumberParser('en-US', {useGrouping: false}).isValidPartialNumber('1234,7')).toBe(false); + expect(new NumberParser('de-DE', {useGrouping: false}).isValidPartialNumber('1234.7')).toBe(false); + }); + it('should reject random characters', function () { expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('g')).toBe(false); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1abc')).toBe(false); diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 5bf765d7dab..17e2454d729 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -258,4 +258,34 @@ describe('NumberField', () => { expect(input).toHaveValue('يومان'); expect(onChange).toHaveBeenLastCalledWith(2); }); + + it('should not type the grouping characters when useGrouping is false', async () => { + let {getByRole} = render(); + let input = getByRole('textbox'); + + await user.keyboard('102,4'); + expect(input).toHaveAttribute('value', '1024'); + + await user.clear(input); + expect(input).toHaveAttribute('value', ''); + + await user.paste('102,4'); + await user.tab(); + expect(input).toHaveAttribute('value', ''); + }); + + it('should not type the grouping characters when useGrouping is false and in German locale', async () => { + let {getByRole} = render(); + let input = getByRole('textbox'); + + await user.keyboard('102.4'); + expect(input).toHaveAttribute('value', '1024'); + + await user.clear(input); + expect(input).toHaveAttribute('value', ''); + + await user.paste('102.4'); + await user.tab(); + expect(input).toHaveAttribute('value', ''); + }); }); From 64959502c7ed112ec7192d52ef9adde6234b5c7e Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 27 Oct 2025 10:37:55 +1100 Subject: [PATCH 11/12] Revert "handle ambiguous group vs decimal case" This reverts commit f459439dc10064f422c63abfa421d60d715d3a84. --- .../number/src/NumberParser.ts | 33 ++++++++----------- .../number/test/NumberParser.test.js | 23 ------------- .../test/NumberField.test.js | 30 ----------------- 3 files changed, 14 insertions(+), 72 deletions(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index 10ad0d60195..39ad757f751 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -132,17 +132,13 @@ class NumberParserImpl { } parse(value: string) { - let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; // 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' let fullySanitizedValue = this.sanitize(value); - // Return NaN if there is a group symbol but useGrouping is false - if (!isGroupSymbolAllowed && this.symbols.group && fullySanitizedValue.includes(this.symbols.group)) { - return NaN; - } else if (this.symbols.group) { - fullySanitizedValue = fullySanitizedValue.replaceAll(this.symbols.group!, ''); + if (this.symbols.group) { + // Remove group characters, and replace decimal points and numerals with ASCII values. + fullySanitizedValue = replaceAll(fullySanitizedValue, this.symbols.group, ''); } - if (this.symbols.decimal) { fullySanitizedValue = fullySanitizedValue.replace(this.symbols.decimal!, '.'); } @@ -195,11 +191,11 @@ class NumberParserImpl { if (this.options.currencySign === 'accounting' && CURRENCY_SIGN_REGEX.test(value)) { newValue = -1 * newValue; } + return newValue; } sanitize(value: string) { - let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; // 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 // return the known value for that case. if (this.symbols.noNumeralUnits.length > 0 && this.symbols.noNumeralUnits.find(obj => obj.unit === value)) { @@ -224,6 +220,7 @@ class NumberParserImpl { value = value.replace(this.symbols.literals, ''); } + // Replace the ASCII minus sign with the minus sign used in the current locale // so that both are allowed in case the user's keyboard doesn't have the locale's minus sign. if (this.symbols.minusSign) { @@ -237,27 +234,27 @@ class NumberParserImpl { value = replaceAll(value, ',', this.symbols.decimal); value = replaceAll(value, String.fromCharCode(1548), this.symbols.decimal); } - if (this.symbols.group && isGroupSymbolAllowed) { + if (this.symbols.group) { value = replaceAll(value, '.', this.symbols.group); } } // In some locale styles, such as swiss currency, the group character can be a special single quote // that keyboards don't typically have. This expands the character to include the easier to type single quote. - if (this.symbols.group === '’' && value.includes("'") && isGroupSymbolAllowed) { + if (this.symbols.group === '’' && value.includes("'")) { value = replaceAll(value, "'", this.symbols.group); } // fr-FR group character is narrow non-breaking space, char code 8239 (U+202F), but that's not a key on the french keyboard, // so allow space and non-breaking space as a group char as well - if (this.options.locale === 'fr-FR' && this.symbols.group && isGroupSymbolAllowed) { + if (this.options.locale === 'fr-FR' && this.symbols.group) { value = replaceAll(value, ' ', this.symbols.group); value = replaceAll(value, /\u00A0/g, this.symbols.group); } // If there are multiple decimal separators and only one group separator, swap them if (this.symbols.decimal - && (this.symbols.group && isGroupSymbolAllowed) + && this.symbols.group && [...value.matchAll(new RegExp(escapeRegex(this.symbols.decimal), 'g'))].length > 1 && [...value.matchAll(new RegExp(escapeRegex(this.symbols.group), 'g'))].length <= 1) { value = swapCharacters(value, this.symbols.decimal, this.symbols.group); @@ -266,7 +263,7 @@ class NumberParserImpl { // If the decimal separator is before the group separator, swap them let decimalIndex = value.indexOf(this.symbols.decimal!); let groupIndex = value.indexOf(this.symbols.group!); - if (this.symbols.decimal && (this.symbols.group && isGroupSymbolAllowed) && decimalIndex > -1 && groupIndex > -1 && decimalIndex < groupIndex) { + if (this.symbols.decimal && this.symbols.group && decimalIndex > -1 && groupIndex > -1 && decimalIndex < groupIndex) { value = swapCharacters(value, this.symbols.decimal, this.symbols.group); } @@ -289,13 +286,13 @@ class NumberParserImpl { let areOnlyGroupAndDecimalSymbols = [...nonDigits].every(char => allPossibleGroupAndDecimalSymbols.has(char)); let oneSymbolNotMatching = ( nonDigits.size === 2 - && (this.symbols.group && isGroupSymbolAllowed) + && this.symbols.group && this.symbols.decimal && (!nonDigits.has(this.symbols.group!) || !nonDigits.has(this.symbols.decimal!)) ); let bothSymbolsNotMatching = ( nonDigits.size === 2 - && (this.symbols.group && isGroupSymbolAllowed) + && this.symbols.group && this.symbols.decimal && !nonDigits.has(this.symbols.group!) && !nonDigits.has(this.symbols.decimal!) ); @@ -321,7 +318,6 @@ class NumberParserImpl { } isValidPartialNumber(value: string, minValue: number = -Infinity, maxValue: number = Infinity): boolean { - let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; value = this.sanitize(value); // Remove minus or plus sign, which must be at the start of the string. @@ -337,7 +333,7 @@ class NumberParserImpl { } // Remove numerals, groups, and decimals - if (this.symbols.group && isGroupSymbolAllowed) { + if (this.symbols.group) { value = replaceAll(value, this.symbols.group, ''); } value = value.replace(this.symbols.numeral, ''); @@ -370,8 +366,7 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I maximumSignificantDigits: 21, roundingIncrement: 1, roundingPriority: 'auto', - roundingMode: 'halfExpand', - useGrouping: true + roundingMode: 'halfExpand' }); // Note: some locale's don't add a group symbol until there is a ten thousands place let allParts = symbolFormatter.formatToParts(-10000.111); diff --git a/packages/@internationalized/number/test/NumberParser.test.js b/packages/@internationalized/number/test/NumberParser.test.js index bc14876ef6c..9590693454b 100644 --- a/packages/@internationalized/number/test/NumberParser.test.js +++ b/packages/@internationalized/number/test/NumberParser.test.js @@ -61,11 +61,6 @@ describe('NumberParser', function () { expect(new NumberParser('en-US', {style: 'decimal'}).parse('1abc')).toBe(NaN); }); - it('should return NaN for invalid grouping', function () { - expect(new NumberParser('en-US', {useGrouping: false}).parse('1234,7')).toBeNaN(); - expect(new NumberParser('de-DE', {useGrouping: false}).parse('1234.7')).toBeNaN(); - }); - describe('currency', function () { it('should parse without the currency symbol', function () { expect(new NumberParser('en-US', {currency: 'USD', style: 'currency'}).parse('10.50')).toBe(10.5); @@ -375,19 +370,6 @@ describe('NumberParser', function () { const formattedOnce = formatter.format(0.0095); expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); }); - it('should handle non-grouping in russian locale', () => { - let locale = 'ru-RU'; - let options = { - style: 'percent', - useGrouping: false, - minimumFractionDigits: undefined, - maximumFractionDigits: undefined - }; - const formatter = new Intl.NumberFormat(locale, options); - const parser = new NumberParser(locale, options); - const formattedOnce = formatter.format(2.220446049250313e-16); - expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); - }); }); }); @@ -424,11 +406,6 @@ describe('NumberParser', function () { expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000,000')).toBe(true); }); - it('should return false for invalid grouping', function () { - expect(new NumberParser('en-US', {useGrouping: false}).isValidPartialNumber('1234,7')).toBe(false); - expect(new NumberParser('de-DE', {useGrouping: false}).isValidPartialNumber('1234.7')).toBe(false); - }); - it('should reject random characters', function () { expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('g')).toBe(false); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1abc')).toBe(false); diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 17e2454d729..5bf765d7dab 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -258,34 +258,4 @@ describe('NumberField', () => { expect(input).toHaveValue('يومان'); expect(onChange).toHaveBeenLastCalledWith(2); }); - - it('should not type the grouping characters when useGrouping is false', async () => { - let {getByRole} = render(); - let input = getByRole('textbox'); - - await user.keyboard('102,4'); - expect(input).toHaveAttribute('value', '1024'); - - await user.clear(input); - expect(input).toHaveAttribute('value', ''); - - await user.paste('102,4'); - await user.tab(); - expect(input).toHaveAttribute('value', ''); - }); - - it('should not type the grouping characters when useGrouping is false and in German locale', async () => { - let {getByRole} = render(); - let input = getByRole('textbox'); - - await user.keyboard('102.4'); - expect(input).toHaveAttribute('value', '1024'); - - await user.clear(input); - expect(input).toHaveAttribute('value', ''); - - await user.paste('102.4'); - await user.tab(); - expect(input).toHaveAttribute('value', ''); - }); }); From c6dcf4da6859d08352f7639f12af098c8d2ad872 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 27 Oct 2025 10:50:43 +1100 Subject: [PATCH 12/12] Reapply "handle ambiguous group vs decimal case" This reverts commit 64959502c7ed112ec7192d52ef9adde6234b5c7e. --- .../number/src/NumberParser.ts | 33 ++++++++------- .../number/test/NumberParser.test.js | 23 +++++++++++ .../test/NumberField.test.js | 41 +++++++++++++++++++ 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index 39ad757f751..10ad0d60195 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -132,13 +132,17 @@ class NumberParserImpl { } parse(value: string) { + let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; // 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' let fullySanitizedValue = this.sanitize(value); - if (this.symbols.group) { - // Remove group characters, and replace decimal points and numerals with ASCII values. - fullySanitizedValue = replaceAll(fullySanitizedValue, this.symbols.group, ''); + // Return NaN if there is a group symbol but useGrouping is false + if (!isGroupSymbolAllowed && this.symbols.group && fullySanitizedValue.includes(this.symbols.group)) { + return NaN; + } else if (this.symbols.group) { + fullySanitizedValue = fullySanitizedValue.replaceAll(this.symbols.group!, ''); } + if (this.symbols.decimal) { fullySanitizedValue = fullySanitizedValue.replace(this.symbols.decimal!, '.'); } @@ -191,11 +195,11 @@ class NumberParserImpl { if (this.options.currencySign === 'accounting' && CURRENCY_SIGN_REGEX.test(value)) { newValue = -1 * newValue; } - return newValue; } sanitize(value: string) { + let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; // 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 // return the known value for that case. if (this.symbols.noNumeralUnits.length > 0 && this.symbols.noNumeralUnits.find(obj => obj.unit === value)) { @@ -220,7 +224,6 @@ class NumberParserImpl { value = value.replace(this.symbols.literals, ''); } - // Replace the ASCII minus sign with the minus sign used in the current locale // so that both are allowed in case the user's keyboard doesn't have the locale's minus sign. if (this.symbols.minusSign) { @@ -234,27 +237,27 @@ class NumberParserImpl { value = replaceAll(value, ',', this.symbols.decimal); value = replaceAll(value, String.fromCharCode(1548), this.symbols.decimal); } - if (this.symbols.group) { + if (this.symbols.group && isGroupSymbolAllowed) { value = replaceAll(value, '.', this.symbols.group); } } // In some locale styles, such as swiss currency, the group character can be a special single quote // that keyboards don't typically have. This expands the character to include the easier to type single quote. - if (this.symbols.group === '’' && value.includes("'")) { + if (this.symbols.group === '’' && value.includes("'") && isGroupSymbolAllowed) { value = replaceAll(value, "'", this.symbols.group); } // fr-FR group character is narrow non-breaking space, char code 8239 (U+202F), but that's not a key on the french keyboard, // so allow space and non-breaking space as a group char as well - if (this.options.locale === 'fr-FR' && this.symbols.group) { + if (this.options.locale === 'fr-FR' && this.symbols.group && isGroupSymbolAllowed) { value = replaceAll(value, ' ', this.symbols.group); value = replaceAll(value, /\u00A0/g, this.symbols.group); } // If there are multiple decimal separators and only one group separator, swap them if (this.symbols.decimal - && this.symbols.group + && (this.symbols.group && isGroupSymbolAllowed) && [...value.matchAll(new RegExp(escapeRegex(this.symbols.decimal), 'g'))].length > 1 && [...value.matchAll(new RegExp(escapeRegex(this.symbols.group), 'g'))].length <= 1) { value = swapCharacters(value, this.symbols.decimal, this.symbols.group); @@ -263,7 +266,7 @@ class NumberParserImpl { // If the decimal separator is before the group separator, swap them let decimalIndex = value.indexOf(this.symbols.decimal!); let groupIndex = value.indexOf(this.symbols.group!); - if (this.symbols.decimal && this.symbols.group && decimalIndex > -1 && groupIndex > -1 && decimalIndex < groupIndex) { + if (this.symbols.decimal && (this.symbols.group && isGroupSymbolAllowed) && decimalIndex > -1 && groupIndex > -1 && decimalIndex < groupIndex) { value = swapCharacters(value, this.symbols.decimal, this.symbols.group); } @@ -286,13 +289,13 @@ class NumberParserImpl { let areOnlyGroupAndDecimalSymbols = [...nonDigits].every(char => allPossibleGroupAndDecimalSymbols.has(char)); let oneSymbolNotMatching = ( nonDigits.size === 2 - && this.symbols.group + && (this.symbols.group && isGroupSymbolAllowed) && this.symbols.decimal && (!nonDigits.has(this.symbols.group!) || !nonDigits.has(this.symbols.decimal!)) ); let bothSymbolsNotMatching = ( nonDigits.size === 2 - && this.symbols.group + && (this.symbols.group && isGroupSymbolAllowed) && this.symbols.decimal && !nonDigits.has(this.symbols.group!) && !nonDigits.has(this.symbols.decimal!) ); @@ -318,6 +321,7 @@ class NumberParserImpl { } isValidPartialNumber(value: string, minValue: number = -Infinity, maxValue: number = Infinity): boolean { + let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; value = this.sanitize(value); // Remove minus or plus sign, which must be at the start of the string. @@ -333,7 +337,7 @@ class NumberParserImpl { } // Remove numerals, groups, and decimals - if (this.symbols.group) { + if (this.symbols.group && isGroupSymbolAllowed) { value = replaceAll(value, this.symbols.group, ''); } value = value.replace(this.symbols.numeral, ''); @@ -366,7 +370,8 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I maximumSignificantDigits: 21, roundingIncrement: 1, roundingPriority: 'auto', - roundingMode: 'halfExpand' + roundingMode: 'halfExpand', + useGrouping: true }); // Note: some locale's don't add a group symbol until there is a ten thousands place let allParts = symbolFormatter.formatToParts(-10000.111); diff --git a/packages/@internationalized/number/test/NumberParser.test.js b/packages/@internationalized/number/test/NumberParser.test.js index 9590693454b..bc14876ef6c 100644 --- a/packages/@internationalized/number/test/NumberParser.test.js +++ b/packages/@internationalized/number/test/NumberParser.test.js @@ -61,6 +61,11 @@ describe('NumberParser', function () { expect(new NumberParser('en-US', {style: 'decimal'}).parse('1abc')).toBe(NaN); }); + it('should return NaN for invalid grouping', function () { + expect(new NumberParser('en-US', {useGrouping: false}).parse('1234,7')).toBeNaN(); + expect(new NumberParser('de-DE', {useGrouping: false}).parse('1234.7')).toBeNaN(); + }); + describe('currency', function () { it('should parse without the currency symbol', function () { expect(new NumberParser('en-US', {currency: 'USD', style: 'currency'}).parse('10.50')).toBe(10.5); @@ -370,6 +375,19 @@ describe('NumberParser', function () { const formattedOnce = formatter.format(0.0095); expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); }); + it('should handle non-grouping in russian locale', () => { + let locale = 'ru-RU'; + let options = { + style: 'percent', + useGrouping: false, + minimumFractionDigits: undefined, + maximumFractionDigits: undefined + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); }); }); @@ -406,6 +424,11 @@ describe('NumberParser', function () { expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000,000')).toBe(true); }); + it('should return false for invalid grouping', function () { + expect(new NumberParser('en-US', {useGrouping: false}).isValidPartialNumber('1234,7')).toBe(false); + expect(new NumberParser('de-DE', {useGrouping: false}).isValidPartialNumber('1234.7')).toBe(false); + }); + it('should reject random characters', function () { expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('g')).toBe(false); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1abc')).toBe(false); diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 5bf765d7dab..b0e7a5a1fc9 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -258,4 +258,45 @@ describe('NumberField', () => { expect(input).toHaveValue('يومان'); expect(onChange).toHaveBeenLastCalledWith(2); }); + + it('should not type the grouping characters when useGrouping is false', async () => { + let {getByRole} = render(); + let input = getByRole('textbox'); + + await user.keyboard('102,4'); + expect(input).toHaveAttribute('value', '1024'); + + await user.clear(input); + expect(input).toHaveAttribute('value', ''); + + await user.paste('102,4'); + await user.tab(); + expect(input).toHaveAttribute('value', ''); + + await user.paste('1,024'); + await user.tab(); + expect(input).toHaveAttribute('value', ''); + // TODO: both of the above should parse to 1024 + + }); + + it('should not type the grouping characters when useGrouping is false and in German locale', async () => { + let {getByRole} = render(); + let input = getByRole('textbox'); + + await user.keyboard('102.4'); + expect(input).toHaveAttribute('value', '1024'); + + await user.clear(input); + expect(input).toHaveAttribute('value', ''); + + await user.paste('102.4'); + await user.tab(); + expect(input).toHaveAttribute('value', ''); + + await user.paste('1.024'); + await user.tab(); + expect(input).toHaveAttribute('value', ''); + // TODO: both of the above should parse to 1024 + }); });