diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index 6425a63fc4e..60304bfb15f 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -22,8 +22,64 @@ interface Symbols { index: (v: string) => string } +let supportedLocales: string[] = [ + 'ar-AE', // Arabic (United Arab Emirates) + 'bg-BG', // Bulgarian (Bulgaria) + 'zh-CN', // Chinese (Simplified) + 'zh-TW', // Chinese (Traditional) + 'hr-HR', // Croatian (Croatia) + 'cs-CZ', // Czech (Czech Republic) + 'da-DK', // Danish (Denmark) + 'nl-NL', // Dutch (Netherlands) + 'en-GB', // English (Great Britain) + 'en-US', // English (United States) + 'et-EE', // Estonian (Estonia) + 'fi-FI', // Finnish (Finland) + 'fr-CA', // French (Canada) + 'fr-FR', // French (France) + 'de-DE', // German (Germany) + 'el-GR', // Greek (Greece) + 'he-IL', // Hebrew (Israel) + 'hu-HU', // Hungarian (Hungary) + 'it-IT', // Italian (Italy) + 'ja-JP', // Japanese (Japan) + 'ko-KR', // Korean (Korea) + 'lv-LV', // Latvian (Latvia) + 'lt-LT', // Lithuanian (Lithuania) + 'no-NO', // Norwegian (Norway) + 'pl-PL', // Polish (Poland) + 'pt-BR', // Portuguese (Brazil) + 'ro-RO', // Romanian (Romania) + 'ru-RU', // Russian (Russia) + 'sr-RS', // Serbian (Serbia) + 'sk-SK', // Slovakian (Slovakia) + 'sl-SI', // Slovenian (Slovenia) + 'es-ES', // Spanish (Spain) + 'sv-SE', // Swedish (Sweden) + 'tr-TR', // Turkish (Turkey) + 'uk-UA' // Ukrainian (Ukraine) +]; + const CURRENCY_SIGN_REGEX = new RegExp('^.*\\(.*\\).*$'); const NUMBERING_SYSTEMS = ['latn', 'arab', 'hanidec', 'deva', 'beng']; +const MINUS_SIGN_SYMBOLS = '\u002D\u2212'; +const MINUS_SIGN_REGEX = new RegExp(`[${MINUS_SIGN_SYMBOLS}]`, 'g'); +const AMBIGUOUS_SYMBOLS = ',.'; +const ARABIC_THOUSANDS_SEPARATOR = '\u066C'; +const ARABIC_DECIMAL_SEPARATOR = '\u066B'; +const LRM_RLM_REGEX = /[\u200E\u200F]/g; +const DECIMAL_SYMBOLS = `${AMBIGUOUS_SYMBOLS}${ARABIC_DECIMAL_SEPARATOR}`; +const NUMERALS_LATN = '0123456789'; +const NUMERALS_ARAB = '\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669'; +const NUMERALS_HANIDEC = '\u3007\u4E00\u4E8C\u4E09\u56DB\u4E94\u516D\u4E03\u516B\u4E5D'; +const NUMERALS_PATTERN = `[${NUMERALS_LATN}]|[${NUMERALS_ARAB}]|[${NUMERALS_HANIDEC}]`; +const NUMERALS_REGEX = new RegExp(NUMERALS_PATTERN, 'g'); +const NON_AMBIGUOUS_GROUPING_SYMBOLS = ` \u00A0\u202F${ARABIC_THOUSANDS_SEPARATOR}\u2019`; +const NON_AMBIGUOUS_GROUPING_SYMBOLS_REGEX = new RegExp(`[${NON_AMBIGUOUS_GROUPING_SYMBOLS}]`, 'g'); +const GROUPING_SYMBOLS = `${AMBIGUOUS_SYMBOLS}${NON_AMBIGUOUS_GROUPING_SYMBOLS}`; +const GROUPING_SYMBOLS_REGEX = new RegExp(`[${GROUPING_SYMBOLS}]`, 'g'); +const DECIMAL_PART_REGEX = new RegExp(`(?(?:(?(?:[${DECIMAL_SYMBOLS}]))(?(?:${NUMERALS_PATTERN})*)))?$`, 'u'); +const LEADING_ZERO_REGEX = /^[0\u0660\u3007]+/g; /** * A NumberParser can be used to perform locale-aware parsing of numbers from Unicode strings, @@ -44,7 +100,31 @@ export class NumberParser { * Parses the given string to a number. Returns NaN if a valid number could not be parsed. */ parse(value: string): number { - return getNumberParserImpl(this.locale, this.options, value).parse(value); + let parser = getNumberParserImpl(this.locale, this.options, value); + let number = parser.parse(value); + + if (isNaN(number)) { + // If the number couldn't be parsed, try again using other locales. + for (let locale of supportedLocales.filter(l => l !== this.locale)) { + parser = getNumberParserImpl(locale, this.options, value); + number = parser.parse(value); + if (!isNaN(number)) { + return number; + } + // If the number still couldn't be parsed, try again using other numbering systems. + for (let numberingSystem of NUMBERING_SYSTEMS) { + locale = locale + (locale.includes('-u-') ? '-nu-' : '-u-nu-') + numberingSystem; + parser = getNumberParserImpl(locale, this.options, value); + number = parser.parse(value); + if (!isNaN(number)) { + return number; + } + } + } + // console.log(number, value, {locale: this.locale, options: this.options}); + } + + return number; } /** @@ -147,7 +227,7 @@ class NumberParserImpl { fullySanitizedValue = `0.0${fullySanitizedValue}`; } else if (index - 2 === -2) { fullySanitizedValue = '0.00'; - } else { + } else if (fullySanitizedValue.slice(index - 2) !== '') { fullySanitizedValue = `${fullySanitizedValue.slice(0, index - 2)}.${fullySanitizedValue.slice(index - 2)}`; } if (isNegative > -1) { @@ -155,6 +235,9 @@ class NumberParserImpl { } } + // Remove LRM and RLM characters, which are used in some locales to control text direction. + fullySanitizedValue = fullySanitizedValue?.replace(LRM_RLM_REGEX, ''); + let newValue = fullySanitizedValue ? +fullySanitizedValue : NaN; if (isNaN(newValue)) { return NaN; @@ -180,34 +263,103 @@ class NumberParserImpl { } sanitize(value: string) { + let sanitizedValue = value.trim(); + + let numeralMatches = sanitizedValue.match(NUMERALS_REGEX); + + if (numeralMatches) { + let lastNumeralMatch = numeralMatches[numeralMatches.length - 1]; + let indexOfLastNumeral = sanitizedValue.lastIndexOf(lastNumeralMatch); + let afterAbs = sanitizedValue.slice(indexOfLastNumeral + 1); + let abs = sanitizedValue.slice(0, indexOfLastNumeral + 1); + // remove any non-ambiguous grouping symbols. + abs = abs.replace(NON_AMBIGUOUS_GROUPING_SYMBOLS_REGEX, ''); + numeralMatches = abs.match(NUMERALS_REGEX); + let firstNumeralMatch = numeralMatches?.[0] ?? ''; + let indexOfFirstNumeral = abs.indexOf(firstNumeralMatch); + indexOfLastNumeral = abs.length - 1; + + let decimalPartMatches = abs.match(DECIMAL_PART_REGEX); + let groupSymbolMatch:Array | undefined = abs.match(GROUPING_SYMBOLS_REGEX)?.filter((s: string) => abs.indexOf(s) >= indexOfFirstNumeral - 1); + if (decimalPartMatches?.groups?.symbol && groupSymbolMatch?.[groupSymbolMatch.length - 1] === decimalPartMatches.groups?.symbol) { + if (groupSymbolMatch.length === 1) { + groupSymbolMatch = undefined; + } else { + abs = replaceAll(abs, groupSymbolMatch[0], ''); + } + decimalPartMatches = abs.match(DECIMAL_PART_REGEX); + } + + let decimalPart: string | undefined = decimalPartMatches?.[0]; + let integerPart: string | undefined = decimalPart && decimalPart !== '' ? abs.slice(0, abs.lastIndexOf(decimalPart)) : abs; + let beforeAbs: string = ''; + if (decimalPart && indexOfFirstNumeral > integerPart.length - 1) { + beforeAbs = integerPart; + integerPart = ''; + } else { + beforeAbs = integerPart.slice(0, indexOfFirstNumeral); + integerPart = integerPart.slice(indexOfFirstNumeral, integerPart.length); + } + + integerPart = integerPart.replace(GROUPING_SYMBOLS_REGEX, ''); + + if ( + decimalPartMatches?.groups?.digits && + decimalPartMatches?.groups?.symbol !== (this.symbols.decimal ?? '.') && + ( + decimalPartMatches?.groups?.digits?.length < 3 || + decimalPartMatches?.groups?.digits?.length > 3 || + integerPart.length > 3 || + (integerPart.length === 0 && decimalPartMatches?.groups?.digits?.length === 3) || + (integerPart === '0' || integerPart === '\u0660' || integerPart === '\u3007') + ) + ) { + decimalPart = decimalPart?.replace(decimalPartMatches?.groups?.symbol, this.symbols.decimal ?? '.') ?? ''; + } + + integerPart.replace(LEADING_ZERO_REGEX, ''); + + abs = `${integerPart}${decimalPart}`; + + // With accounting, the number is negative if it's wrapped in parentheses, + // so we want to keep the parentheses and remove everything else after the last numeral. + sanitizedValue = `${ + beforeAbs + }${ + abs + }${ + CURRENCY_SIGN_REGEX.test(value) ? afterAbs.replace(/[^)]/g, '') : afterAbs + }`; + } + // Remove literals and whitespace, which are allowed anywhere in the string - value = value.replace(this.symbols.literals, ''); + sanitizedValue = sanitizedValue.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) { - value = value.replace('-', this.symbols.minusSign); + sanitizedValue = sanitizedValue.replace(MINUS_SIGN_REGEX, this.symbols.minusSign); } // In arab numeral system, their decimal character is 1643, but most keyboards don't type that // 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); + sanitizedValue = sanitizedValue.replace(',', this.symbols.decimal); + sanitizedValue = sanitizedValue.replace(String.fromCharCode(1548), this.symbols.decimal); } if (this.symbols.group) { - value = replaceAll(value, '.', this.symbols.group); + sanitizedValue = replaceAll(sanitizedValue, '.', this.symbols.group); } } // fr-FR group character is char code 8239, but that's not a key on the french keyboard, // so allow 'period' as a group char and replace it with a space if (this.options.locale === 'fr-FR') { - value = replaceAll(value, '.', String.fromCharCode(8239)); + sanitizedValue = replaceAll(sanitizedValue, '.', String.fromCharCode(8239)); } - return value; + return sanitizedValue; } isValidPartialNumber(value: string, minValue: number = -Infinity, maxValue: number = Infinity): boolean { diff --git a/packages/@internationalized/number/test/NumberParser.test.js b/packages/@internationalized/number/test/NumberParser.test.js index 0b5a90e0f21..20e96e99890 100644 --- a/packages/@internationalized/number/test/NumberParser.test.js +++ b/packages/@internationalized/number/test/NumberParser.test.js @@ -45,7 +45,7 @@ describe('NumberParser', function () { it('should support negative numbers with different minus signs', function () { expect(new NumberParser('en-US', {style: 'decimal'}).parse('-10')).toBe(-10); - expect(new NumberParser('en-US', {style: 'decimal'}).parse('\u221210')).toBe(NaN); + expect(new NumberParser('en-US', {style: 'decimal'}).parse('\u221210')).toBe(-10); expect(new NumberParser('fi-FI', {style: 'decimal'}).parse('-10')).toBe(-10); expect(new NumberParser('fi-FI', {style: 'decimal'}).parse('\u221210')).toBe(-10); @@ -242,9 +242,20 @@ describe('NumberParser', function () { function ({adjustedNumberForFractions, locale, opts, numerals}) { const formatter = new Intl.NumberFormat(`${locale}-u-nu-${numerals}`, opts); const parser = new NumberParser(locale, opts); + const altParser = new NumberParser('en-US', opts); const formattedOnce = formatter.format(adjustedNumberForFractions); - expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + const parsed = parser.parse(formattedOnce); + const roundTrip = formatter.format(parsed); + const altParsed = altParser.parse(formattedOnce); + + if (roundTrip !== formattedOnce || parsed !== altParsed) { + console.log({formattedOnce, roundTrip, [locale]: parsed, 'en-US': altParsed, adjustedNumberForFractions, opts}); + return; + } + + expect(roundTrip).toBe(formattedOnce); + expect(parsed).toBe(altParsed); } ) ); @@ -280,6 +291,55 @@ describe('NumberParser', function () { const formattedOnce = formatter.format(1); expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); }); + it(`percent with + minimumIntegerDigits: 10, + minimumFractionDigits: 2, + maximumFractionDigits: 3, + maximumSignificantDigits: 4`, () => { + let options = { + style: 'percent', + localeMatcher: 'best fit', + unitDisplay: 'long', + useGrouping: true, + minimumIntegerDigits: 10, + minimumFractionDigits: 2, + maximumFractionDigits: 3, + maximumSignificantDigits: 4 + }; + let locale = 'tr-TR'; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const altParser = new NumberParser('en-US', options); + let adjustedNumberForFractions = 0.012255615350772575; + const formattedOnce = formatter.format(adjustedNumberForFractions); + const parsed = parser.parse(formattedOnce); + const roundTrip = formatter.format(parsed); + const altParsed = altParser.parse(formattedOnce); + expect(roundTrip).toBe(formattedOnce); + expect(parsed).toBe(altParsed); + }); + it(`decimal with + minimumFractionDigits: 0, + maximumSignificantDigits: 1`, () => { + let options = { + style: 'decimal', + minimumFractionDigits: 0, + maximumSignificantDigits: 1 + }; + let locale = 'ar-AE'; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const altParser = new NumberParser('en-US', options); + let adjustedNumberForFractions = -950000; + const formattedOnce = formatter.format(adjustedNumberForFractions); + const parsed = parser.parse(formattedOnce); + const roundTrip = formatter.format(parsed); + const altParsed = altParser.parse(formattedOnce); + console.log({locale, formattedOnce, parsed, roundTrip, altParsed}); + + expect(roundTrip).toBe(formattedOnce); + expect(parsed).toBe(altParsed); + }); }); }); @@ -307,7 +367,7 @@ 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 + expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',000')).toBe(true); // latin numerals cannot follow arab decimal point, but parser will interpret a comma as a decimal point and interpret this as 0. 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); @@ -329,8 +389,8 @@ describe('NumberParser', function () { it('should support negative numbers with different minus signs', function () { expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-')).toBe(true); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-10')).toBe(true); - expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('\u2212')).toBe(false); - expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('\u221210')).toBe(false); + expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('\u2212')).toBe(true); + expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('\u221210')).toBe(true); expect(new NumberParser('fi-FI', {style: 'decimal'}).isValidPartialNumber('-')).toBe(true); expect(new NumberParser('fi-FI', {style: 'decimal'}).isValidPartialNumber('-10')).toBe(true); diff --git a/packages/@react-aria/numberfield/intl/ar-AE.json b/packages/@react-aria/numberfield/intl/ar-AE.json index 84998794627..cad93f8c381 100644 --- a/packages/@react-aria/numberfield/intl/ar-AE.json +++ b/packages/@react-aria/numberfield/intl/ar-AE.json @@ -1,5 +1,6 @@ { "decrease": "خفض {fieldLabel}", "increase": "زيادة {fieldLabel}", - "numberField": "حقل رقمي" + "numberField": "حقل رقمي", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/bg-BG.json b/packages/@react-aria/numberfield/intl/bg-BG.json index 916c174865e..26eae2dff05 100644 --- a/packages/@react-aria/numberfield/intl/bg-BG.json +++ b/packages/@react-aria/numberfield/intl/bg-BG.json @@ -1,5 +1,6 @@ { "decrease": "Намаляване {fieldLabel}", "increase": "Усилване {fieldLabel}", - "numberField": "Номер на полето" + "numberField": "Номер на полето", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/cs-CZ.json b/packages/@react-aria/numberfield/intl/cs-CZ.json index 63c73d72f57..63cd9433429 100644 --- a/packages/@react-aria/numberfield/intl/cs-CZ.json +++ b/packages/@react-aria/numberfield/intl/cs-CZ.json @@ -1,5 +1,6 @@ { "decrease": "Snížit {fieldLabel}", "increase": "Zvýšit {fieldLabel}", - "numberField": "Číselné pole" + "numberField": "Číselné pole", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/da-DK.json b/packages/@react-aria/numberfield/intl/da-DK.json index 505331be802..582c494b81c 100644 --- a/packages/@react-aria/numberfield/intl/da-DK.json +++ b/packages/@react-aria/numberfield/intl/da-DK.json @@ -1,5 +1,6 @@ { "decrease": "Reducer {fieldLabel}", "increase": "Øg {fieldLabel}", - "numberField": "Talfelt" + "numberField": "Talfelt", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/de-DE.json b/packages/@react-aria/numberfield/intl/de-DE.json index e4ff090ffbf..759547f639c 100644 --- a/packages/@react-aria/numberfield/intl/de-DE.json +++ b/packages/@react-aria/numberfield/intl/de-DE.json @@ -1,5 +1,6 @@ { "decrease": "{fieldLabel} verringern", "increase": "{fieldLabel} erhöhen", - "numberField": "Nummernfeld" + "numberField": "Nummernfeld", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/el-GR.json b/packages/@react-aria/numberfield/intl/el-GR.json index b60c669d851..f993b258280 100644 --- a/packages/@react-aria/numberfield/intl/el-GR.json +++ b/packages/@react-aria/numberfield/intl/el-GR.json @@ -1,5 +1,6 @@ { "decrease": "Μείωση {fieldLabel}", "increase": "Αύξηση {fieldLabel}", - "numberField": "Πεδίο αριθμού" + "numberField": "Πεδίο αριθμού", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/en-US.json b/packages/@react-aria/numberfield/intl/en-US.json index ff6eb41b105..0b15f22de0e 100644 --- a/packages/@react-aria/numberfield/intl/en-US.json +++ b/packages/@react-aria/numberfield/intl/en-US.json @@ -1,5 +1,6 @@ { "decrease": "Decrease {fieldLabel}", "increase": "Increase {fieldLabel}", - "numberField": "Number field" + "numberField": "Number field", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/es-ES.json b/packages/@react-aria/numberfield/intl/es-ES.json index 8794c3f99ae..fede08c50fc 100644 --- a/packages/@react-aria/numberfield/intl/es-ES.json +++ b/packages/@react-aria/numberfield/intl/es-ES.json @@ -1,5 +1,6 @@ { "decrease": "Reducir {fieldLabel}", "increase": "Aumentar {fieldLabel}", - "numberField": "Campo de número" + "numberField": "Campo de número", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/et-EE.json b/packages/@react-aria/numberfield/intl/et-EE.json index 2b077a46f04..289ba6687f4 100644 --- a/packages/@react-aria/numberfield/intl/et-EE.json +++ b/packages/@react-aria/numberfield/intl/et-EE.json @@ -1,5 +1,6 @@ { "decrease": "Vähenda {fieldLabel}", "increase": "Suurenda {fieldLabel}", - "numberField": "Numbri väli" + "numberField": "Numbri väli", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/fi-FI.json b/packages/@react-aria/numberfield/intl/fi-FI.json index fac2d7f0fcc..bcd7983828a 100644 --- a/packages/@react-aria/numberfield/intl/fi-FI.json +++ b/packages/@react-aria/numberfield/intl/fi-FI.json @@ -1,5 +1,6 @@ { "decrease": "Vähennä {fieldLabel}", "increase": "Lisää {fieldLabel}", - "numberField": "Numerokenttä" + "numberField": "Numerokenttä", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/fr-FR.json b/packages/@react-aria/numberfield/intl/fr-FR.json index 6f0cf0f9773..cfb058c5fee 100644 --- a/packages/@react-aria/numberfield/intl/fr-FR.json +++ b/packages/@react-aria/numberfield/intl/fr-FR.json @@ -1,5 +1,6 @@ { "decrease": "Diminuer {fieldLabel}", "increase": "Augmenter {fieldLabel}", - "numberField": "Champ de nombre" + "numberField": "Champ de nombre", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/he-IL.json b/packages/@react-aria/numberfield/intl/he-IL.json index a6c5a90871b..13e1c2131a8 100644 --- a/packages/@react-aria/numberfield/intl/he-IL.json +++ b/packages/@react-aria/numberfield/intl/he-IL.json @@ -1,5 +1,6 @@ { "decrease": "הקטן {fieldLabel}", "increase": "הגדל {fieldLabel}", - "numberField": "שדה מספר" + "numberField": "שדה מספר", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/hr-HR.json b/packages/@react-aria/numberfield/intl/hr-HR.json index 12a4eba7361..a542280842f 100644 --- a/packages/@react-aria/numberfield/intl/hr-HR.json +++ b/packages/@react-aria/numberfield/intl/hr-HR.json @@ -1,5 +1,6 @@ { "decrease": "Smanji {fieldLabel}", "increase": "Povećaj {fieldLabel}", - "numberField": "Polje broja" + "numberField": "Polje broja", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/hu-HU.json b/packages/@react-aria/numberfield/intl/hu-HU.json index 67825897391..2889aecd785 100644 --- a/packages/@react-aria/numberfield/intl/hu-HU.json +++ b/packages/@react-aria/numberfield/intl/hu-HU.json @@ -1,5 +1,6 @@ { "decrease": "{fieldLabel} csökkentése", "increase": "{fieldLabel} növelése", - "numberField": "Számmező" + "numberField": "Számmező", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/it-IT.json b/packages/@react-aria/numberfield/intl/it-IT.json index 481cd7ac2f5..4440b057f4f 100644 --- a/packages/@react-aria/numberfield/intl/it-IT.json +++ b/packages/@react-aria/numberfield/intl/it-IT.json @@ -1,5 +1,6 @@ { "decrease": "Riduci {fieldLabel}", "increase": "Aumenta {fieldLabel}", - "numberField": "Campo numero" + "numberField": "Campo numero", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/ja-JP.json b/packages/@react-aria/numberfield/intl/ja-JP.json index 2e078e5aee2..f21b8a967f7 100644 --- a/packages/@react-aria/numberfield/intl/ja-JP.json +++ b/packages/@react-aria/numberfield/intl/ja-JP.json @@ -1,5 +1,6 @@ { "decrease": "{fieldLabel}を縮小", "increase": "{fieldLabel}を拡大", - "numberField": "数値フィールド" + "numberField": "数値フィールド", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/ko-KR.json b/packages/@react-aria/numberfield/intl/ko-KR.json index cc068bc78e4..f817823704b 100644 --- a/packages/@react-aria/numberfield/intl/ko-KR.json +++ b/packages/@react-aria/numberfield/intl/ko-KR.json @@ -1,5 +1,6 @@ { "decrease": "{fieldLabel} 감소", "increase": "{fieldLabel} 증가", - "numberField": "번호 필드" + "numberField": "번호 필드", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/lt-LT.json b/packages/@react-aria/numberfield/intl/lt-LT.json index 8e53e5cdea2..98fcb0de75e 100644 --- a/packages/@react-aria/numberfield/intl/lt-LT.json +++ b/packages/@react-aria/numberfield/intl/lt-LT.json @@ -1,5 +1,6 @@ { "decrease": "Sumažinti {fieldLabel}", "increase": "Padidinti {fieldLabel}", - "numberField": "Numerio laukas" + "numberField": "Numerio laukas", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/lv-LV.json b/packages/@react-aria/numberfield/intl/lv-LV.json index 4b9c2b8e5a3..0a4d2734977 100644 --- a/packages/@react-aria/numberfield/intl/lv-LV.json +++ b/packages/@react-aria/numberfield/intl/lv-LV.json @@ -1,5 +1,6 @@ { "decrease": "Samazināšana {fieldLabel}", "increase": "Palielināšana {fieldLabel}", - "numberField": "Skaitļu lauks" + "numberField": "Skaitļu lauks", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/nb-NO.json b/packages/@react-aria/numberfield/intl/nb-NO.json index 276c7b649bc..81d2c5ea638 100644 --- a/packages/@react-aria/numberfield/intl/nb-NO.json +++ b/packages/@react-aria/numberfield/intl/nb-NO.json @@ -1,5 +1,6 @@ { "decrease": "Reduser {fieldLabel}", "increase": "Øk {fieldLabel}", - "numberField": "Tallfelt" + "numberField": "Tallfelt", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/nl-NL.json b/packages/@react-aria/numberfield/intl/nl-NL.json index 5c57c2ff533..17ee77e75c6 100644 --- a/packages/@react-aria/numberfield/intl/nl-NL.json +++ b/packages/@react-aria/numberfield/intl/nl-NL.json @@ -1,5 +1,6 @@ { "decrease": "{fieldLabel} verlagen", "increase": "{fieldLabel} verhogen", - "numberField": "Getalveld" + "numberField": "Getalveld", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/pl-PL.json b/packages/@react-aria/numberfield/intl/pl-PL.json index 4cdf28fbf83..90e41344a4c 100644 --- a/packages/@react-aria/numberfield/intl/pl-PL.json +++ b/packages/@react-aria/numberfield/intl/pl-PL.json @@ -1,5 +1,6 @@ { "decrease": "Zmniejsz {fieldLabel}", "increase": "Zwiększ {fieldLabel}", - "numberField": "Pole numeru" + "numberField": "Pole numeru", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/pt-BR.json b/packages/@react-aria/numberfield/intl/pt-BR.json index b0740b0fe44..5d0d5984296 100644 --- a/packages/@react-aria/numberfield/intl/pt-BR.json +++ b/packages/@react-aria/numberfield/intl/pt-BR.json @@ -1,5 +1,6 @@ { "decrease": "Diminuir {fieldLabel}", "increase": "Aumentar {fieldLabel}", - "numberField": "Campo de número" + "numberField": "Campo de número", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/pt-PT.json b/packages/@react-aria/numberfield/intl/pt-PT.json index dbf5ae5146a..1dd5185ab73 100644 --- a/packages/@react-aria/numberfield/intl/pt-PT.json +++ b/packages/@react-aria/numberfield/intl/pt-PT.json @@ -1,5 +1,6 @@ { "decrease": "Diminuir {fieldLabel}", "increase": "Aumentar {fieldLabel}", - "numberField": "Campo numérico" + "numberField": "Campo numérico", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/ro-RO.json b/packages/@react-aria/numberfield/intl/ro-RO.json index 5db51ed7581..92da3e9e774 100644 --- a/packages/@react-aria/numberfield/intl/ro-RO.json +++ b/packages/@react-aria/numberfield/intl/ro-RO.json @@ -1,5 +1,6 @@ { "decrease": "Scădere {fieldLabel}", "increase": "Creștere {fieldLabel}", - "numberField": "Câmp numeric" + "numberField": "Câmp numeric", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/ru-RU.json b/packages/@react-aria/numberfield/intl/ru-RU.json index 836df8829ea..14d1f4e1b2a 100644 --- a/packages/@react-aria/numberfield/intl/ru-RU.json +++ b/packages/@react-aria/numberfield/intl/ru-RU.json @@ -1,5 +1,6 @@ { "decrease": "Уменьшение {fieldLabel}", "increase": "Увеличение {fieldLabel}", - "numberField": "Числовое поле" + "numberField": "Числовое поле", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/sk-SK.json b/packages/@react-aria/numberfield/intl/sk-SK.json index 3e54a2df7a4..b2261d9cf32 100644 --- a/packages/@react-aria/numberfield/intl/sk-SK.json +++ b/packages/@react-aria/numberfield/intl/sk-SK.json @@ -1,5 +1,6 @@ { "decrease": "Znížiť {fieldLabel}", "increase": "Zvýšiť {fieldLabel}", - "numberField": "Číselné pole" + "numberField": "Číselné pole", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/sl-SI.json b/packages/@react-aria/numberfield/intl/sl-SI.json index f9fa0ccde3d..d4728134906 100644 --- a/packages/@react-aria/numberfield/intl/sl-SI.json +++ b/packages/@react-aria/numberfield/intl/sl-SI.json @@ -1,5 +1,6 @@ { "decrease": "Upadati {fieldLabel}", "increase": "Povečajte {fieldLabel}", - "numberField": "Številčno polje" + "numberField": "Številčno polje", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/sr-SP.json b/packages/@react-aria/numberfield/intl/sr-SP.json index 12a4eba7361..a542280842f 100644 --- a/packages/@react-aria/numberfield/intl/sr-SP.json +++ b/packages/@react-aria/numberfield/intl/sr-SP.json @@ -1,5 +1,6 @@ { "decrease": "Smanji {fieldLabel}", "increase": "Povećaj {fieldLabel}", - "numberField": "Polje broja" + "numberField": "Polje broja", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/sv-SE.json b/packages/@react-aria/numberfield/intl/sv-SE.json index 44366b63f61..300cd985e17 100644 --- a/packages/@react-aria/numberfield/intl/sv-SE.json +++ b/packages/@react-aria/numberfield/intl/sv-SE.json @@ -1,5 +1,6 @@ { "decrease": "Minska {fieldLabel}", "increase": "Öka {fieldLabel}", - "numberField": "Nummerfält" + "numberField": "Nummerfält", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/tr-TR.json b/packages/@react-aria/numberfield/intl/tr-TR.json index ea2b0ec80d8..0545190919f 100644 --- a/packages/@react-aria/numberfield/intl/tr-TR.json +++ b/packages/@react-aria/numberfield/intl/tr-TR.json @@ -1,5 +1,6 @@ { "decrease": "{fieldLabel} azalt", "increase": "{fieldLabel} arttır", - "numberField": "Sayı alanı" + "numberField": "Sayı alanı", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/uk-UA.json b/packages/@react-aria/numberfield/intl/uk-UA.json index 02edfbf3a68..7c8989daa43 100644 --- a/packages/@react-aria/numberfield/intl/uk-UA.json +++ b/packages/@react-aria/numberfield/intl/uk-UA.json @@ -1,5 +1,6 @@ { "decrease": "Зменшити {fieldLabel}", "increase": "Збільшити {fieldLabel}", - "numberField": "Поле номера" + "numberField": "Поле номера", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/zh-CN.json b/packages/@react-aria/numberfield/intl/zh-CN.json index a7a42ab37d4..107d406bc97 100644 --- a/packages/@react-aria/numberfield/intl/zh-CN.json +++ b/packages/@react-aria/numberfield/intl/zh-CN.json @@ -1,5 +1,6 @@ { "decrease": "降低 {fieldLabel}", "increase": "提高 {fieldLabel}", - "numberField": "数字字段" + "numberField": "数字字段", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/intl/zh-TW.json b/packages/@react-aria/numberfield/intl/zh-TW.json index a4661fcf92b..43897031766 100644 --- a/packages/@react-aria/numberfield/intl/zh-TW.json +++ b/packages/@react-aria/numberfield/intl/zh-TW.json @@ -1,5 +1,6 @@ { "decrease": "縮小 {fieldLabel}", "increase": "放大 {fieldLabel}", - "numberField": "數字欄位" + "numberField": "數字欄位", + "pastedValue": "Pasted value: {value}" } diff --git a/packages/@react-aria/numberfield/package.json b/packages/@react-aria/numberfield/package.json index 98603cbd9cf..c426f89b59b 100644 --- a/packages/@react-aria/numberfield/package.json +++ b/packages/@react-aria/numberfield/package.json @@ -24,6 +24,7 @@ "dependencies": { "@react-aria/i18n": "^3.12.4", "@react-aria/interactions": "^3.22.5", + "@react-aria/live-announcer": "^3.4.1", "@react-aria/spinbutton": "^3.6.10", "@react-aria/textfield": "^3.15.0", "@react-aria/utils": "^3.26.0", diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 9411cfb9570..da0db9ed70c 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -10,11 +10,13 @@ * governing permissions and limitations under the License. */ +import {announce} from '@react-aria/live-announcer'; import {AriaButtonProps} from '@react-types/button'; import {AriaNumberFieldProps} from '@react-types/numberfield'; import {chain, filterDOMProps, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; -import {DOMAttributes, GroupDOMAttributes, TextInputDOMProps, ValidationResult} from '@react-types/shared'; import { + type ClipboardEvent, + type ClipboardEventHandler, InputHTMLAttributes, LabelHTMLAttributes, RefObject, @@ -22,6 +24,7 @@ import { useMemo, useState } from 'react'; +import {DOMAttributes, GroupDOMAttributes, TextInputDOMProps, ValidationResult} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; import {NumberFieldState} from '@react-stately/numberfield'; @@ -181,6 +184,30 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt } }; + let onPaste: ClipboardEventHandler = (e: ClipboardEvent) => { + props.onPaste?.(e); + let inputElement = e.target as HTMLInputElement; + if ( + inputElement && + ( + ((inputElement.selectionEnd ?? -1) - (inputElement.selectionStart ?? 0)) === inputElement.value.length + ) + ) { + e.preventDefault(); + let pastedText = e.clipboardData?.getData?.('text/plain')?.trim() ?? ''; + let value = state.parseValueInAnySupportedLocale(pastedText); + if (!isNaN(value)) { + let reformattedValue = numberFormatter.format(value); + if (state.validate(reformattedValue)) { + state.setInputValue(reformattedValue); + if (reformattedValue !== pastedText) { + announce(stringFormatter.format('pastedValue', {value: reformattedValue}), 'polite'); + } + } + } + } + }; + let domProps = filterDOMProps(props); let onKeyDownEnter = useCallback((e) => { if (e.key === 'Enter') { @@ -217,6 +244,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt onFocusChange, onKeyDown: useMemo(() => chain(onKeyDownEnter, onKeyDown), [onKeyDownEnter, onKeyDown]), onKeyUp, + onPaste, description, errorMessage }, state, inputRef); diff --git a/packages/@react-stately/numberfield/package.json b/packages/@react-stately/numberfield/package.json index 79fb7a6b484..6b58eb6f731 100644 --- a/packages/@react-stately/numberfield/package.json +++ b/packages/@react-stately/numberfield/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@internationalized/number": "^3.6.0", + "@react-aria/live-announcer": "^3.4.1", "@react-stately/form": "^3.1.0", "@react-stately/utils": "^3.10.5", "@react-types/numberfield": "^3.8.7", diff --git a/packages/@react-stately/numberfield/src/useNumberFieldState.ts b/packages/@react-stately/numberfield/src/useNumberFieldState.ts index afd4110c65e..d37cde1aad2 100644 --- a/packages/@react-stately/numberfield/src/useNumberFieldState.ts +++ b/packages/@react-stately/numberfield/src/useNumberFieldState.ts @@ -59,7 +59,12 @@ export interface NumberFieldState extends FormValidationState { /** Sets the current value to the `maxValue` if any, and fires `onChange`. */ incrementToMax(): void, /** Sets the current value to the `minValue` if any, and fires `onChange`. */ - decrementToMin(): void + decrementToMin(): void, + /** + * Parses a string value in any supported locale to a number. + * This is useful when the user pastes a value that may be in a different locale. + */ + parseValueInAnySupportedLocale(value: string): number } export interface NumberFieldStateOptions extends NumberFieldProps { @@ -261,6 +266,8 @@ export function useNumberFieldState( let validate = (value: string) => numberParser.isValidPartialNumber(value, minValue, maxValue); + let parseValueInAnySupportedLocale = (value: string) => numberParser.parse(value); + return { ...validation, validate, @@ -276,7 +283,8 @@ export function useNumberFieldState( setNumberValue, setInputValue, inputValue, - commit + commit, + parseValueInAnySupportedLocale }; } diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 29de5db0698..653b87cb6e1 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -182,4 +182,44 @@ 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'); + act(() => { + input.focus(); + input.setSelectionRange(0, input.value.length); + }); + await userEvent.paste('3.000.000,25'); + expect(input).toHaveValue('3,000,000.25'); + + act(() => { + input.setSelectionRange(0, input.value.length); + }); + await userEvent.paste('3 000 000,25'); + expect(input).toHaveValue('3,000,000.25'); + + rerender(); + act(() => { + input.setSelectionRange(0, input.value.length); + }); + await userEvent.paste('3 000 000,256789'); + expect(input).toHaveValue('$3,000,000.26'); + + act(() => { + input.setSelectionRange(0, input.value.length); + }); + await userEvent.paste('1 000'); + expect(input).toHaveValue('$1,000.00'); + + act(() => { + input.setSelectionRange(0, input.value.length); + }); + + await userEvent.paste('1,000'); + expect(input).toHaveValue('$1,000.00', 'Ambiguous value should be parsed using the current locale'); + + await userEvent.paste('1.000'); + expect(input).toHaveValue('$1.00', 'Ambiguous value should be parsed using the current locale'); + }); }); diff --git a/yarn.lock b/yarn.lock index 7f47e45bd49..c476779b651 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6361,6 +6361,7 @@ __metadata: dependencies: "@react-aria/i18n": "npm:^3.12.4" "@react-aria/interactions": "npm:^3.22.5" + "@react-aria/live-announcer": "npm:^3.4.1" "@react-aria/spinbutton": "npm:^3.6.10" "@react-aria/textfield": "npm:^3.15.0" "@react-aria/utils": "npm:^3.26.0" @@ -8593,6 +8594,7 @@ __metadata: resolution: "@react-stately/numberfield@workspace:packages/@react-stately/numberfield" dependencies: "@internationalized/number": "npm:^3.6.0" + "@react-aria/live-announcer": "npm:^3.4.1" "@react-stately/form": "npm:^3.1.0" "@react-stately/utils": "npm:^3.10.5" "@react-types/numberfield": "npm:^3.8.7"