Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 128 additions & 22 deletions packages/@internationalized/number/src/NumberParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ interface Symbols {
group?: string,
literals: RegExp,
numeral: RegExp,
index: (v: string) => string
numerals: string[],
index: (v: string) => string,
noNumeralUnits: Array<{unit: string, value: number}>
}

const CURRENCY_SIGN_REGEX = new RegExp('^.*\\(.*\\).*$');
Expand Down Expand Up @@ -130,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!, '.');
}
Expand Down Expand Up @@ -189,13 +195,34 @@ class NumberParserImpl {
if (this.options.currencySign === 'accounting' && CURRENCY_SIGN_REGEX.test(value)) {
newValue = -1 * newValue;
}

return newValue;
}

sanitize(value: string) {
// Remove literals and whitespace, which are allowed anywhere in the string
value = value.replace(this.symbols.literals, '');
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)) {
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) {
// 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
// so that both are allowed in case the user's keyboard doesn't have the locale's minus sign.
Expand All @@ -207,31 +234,94 @@ 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) {
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 && 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);
}

// 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) {
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 numbering system's "0" character,
// 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, '');
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(''));

// 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 && isGroupSymbolAllowed)
&& this.symbols.decimal
&& (!nonDigits.has(this.symbols.group!) || !nonDigits.has(this.symbols.decimal!))
);
let bothSymbolsNotMatching = (
nonDigits.size === 2
&& (this.symbols.group && isGroupSymbolAllowed)
&& 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;
}

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.
Expand All @@ -241,18 +331,13 @@ 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;
}

// 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, '');
Expand All @@ -267,6 +352,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
Expand All @@ -282,12 +370,21 @@ 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);
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;
Expand All @@ -311,17 +408,26 @@ 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();
let indexes = new Map(numerals.map((d, i) => [d, 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, noNumeralUnits};
}

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) {
Expand Down
100 changes: 97 additions & 3 deletions packages/@internationalized/number/test/NumberParser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -56,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);
Expand Down Expand Up @@ -194,8 +204,13 @@ 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: 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);
Expand Down Expand Up @@ -301,6 +316,78 @@ 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 small numbers', () => {
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 small numbers', () => {
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 with minimum integer digits', () => {
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);
});
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);
});
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);
});
});
});

Expand All @@ -327,14 +414,21 @@ 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);
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);
Expand Down
Loading