feat: format number add thousandSeparator option, fix type decimalSep (#1774)

This commit is contained in:
Joren Broekema 2022-09-08 09:19:07 +02:00 committed by GitHub
parent 11c5ffe094
commit 2d58320e51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 240 additions and 21 deletions

View file

@ -0,0 +1,6 @@
---
'@lion/input-amount': minor
'@lion/localize': minor
---
Allow specifying thousandSeparator for format number. BREAKING: change decimalSeparator type to only be ',' or '.'.

View file

@ -108,15 +108,15 @@ export const noDecimals = () => html`
For copy pasting numbers into the input-amount, there is slightly different parsing behavior. For copy pasting numbers into the input-amount, there is slightly different parsing behavior.
Normally, when it receives an input with only 1 separator character, we check the locale to determine whether this character is a thousand separator, or a decimal separator. Normally, when it receives an input with only 1 separator character, we check the locale to determine whether this character is a group (thousand) separator, or a decimal separator.
When a user pastes the input from a different source, we find this approach (locale-based) quite unreliable, because it may have been copied from a 'mathematical context' (like an Excel sheet) or a context with a different locale. When a user pastes the input from a different source, we find this approach (locale-based) quite unreliable, because it may have been copied from a 'mathematical context' (like an Excel sheet) or a context with a different locale.
Therefore, we use the heuristics based method to parse the input when it is pasted by the user. Therefore, we use the heuristics based method to parse the input when it is pasted by the user.
### What this means ### What this means
If the user in an English locale types `400,0` it will become `4,000.00` If the user in an English locale types `400,0` it will become `4,000.00`
because the locale determines that the comma is a thousand separator, not a decimal separator. because the locale determines that the comma is a group separator, not a decimal separator.
If the user in an English locale pastes `400,0` instead, it will become `400.00` because we cannot rely on locale. If the user in an English locale pastes `400,0` instead, it will become `400.00` because we cannot rely on locale.
Therefore, instead, we determine that the comma cannot be a thousand separator because it is not followed by 3 digits after. Therefore, instead, we determine that the comma cannot be a group separator because it is not followed by 3 digits after.
It is more likely to be a decimal separator. It is more likely to be a decimal separator.

View file

@ -89,7 +89,7 @@ export const unparseable = () => html`
A formatter should return a `formattedValue`. It accepts the current modelValue and an options object. A formatter should return a `formattedValue`. It accepts the current modelValue and an options object.
Below is a very naive and limited parser that ignores non-digits. The formatter then uses `Intl.NumberFormat` to format it with thousand separators. Below is a very naive and limited parser that ignores non-digits. The formatter then uses `Intl.NumberFormat` to format it with group (thousand) separators.
Formatted value is reflected back to the user `on-blur` of the field, but only if the field has no errors (validation). Formatted value is reflected back to the user `on-blur` of the field, but only if the field has no errors (validation).

View file

@ -70,6 +70,18 @@ describe('<lion-input-amount>', () => {
expect(el.formattedValue).to.equal('99.00'); expect(el.formattedValue).to.equal('99.00');
}); });
it('supports overriding groupSeparator in formatOptions', async () => {
const el = /** @type {LionInputAmount} */ (
await fixture(
html`<lion-input-amount
.formatOptions="${{ locale: 'nl-NL', groupSeparator: ',', decimalSeparator: '.' }}"
.modelValue="${9999}"
></lion-input-amount>`,
)
);
expect(el.formattedValue).to.equal('9,999.00');
});
it('ignores global locale change if property is provided', async () => { it('ignores global locale change if property is provided', async () => {
const el = /** @type {LionInputAmount} */ ( const el = /** @type {LionInputAmount} */ (
await fixture(html` await fixture(html`

View file

@ -13,5 +13,6 @@ export { getCurrencyName } from './src/number/getCurrencyName.js';
export { getDecimalSeparator } from './src/number/getDecimalSeparator.js'; export { getDecimalSeparator } from './src/number/getDecimalSeparator.js';
export { getFractionDigits } from './src/number/getFractionDigits.js'; export { getFractionDigits } from './src/number/getFractionDigits.js';
export { getGroupSeparator } from './src/number/getGroupSeparator.js'; export { getGroupSeparator } from './src/number/getGroupSeparator.js';
export { getSeparatorsFromNumber } from './src/number/getSeparatorsFromNumber.js';
export { normalizeCurrencyLabel } from './src/number/normalizeCurrencyLabel.js'; export { normalizeCurrencyLabel } from './src/number/normalizeCurrencyLabel.js';
export { parseNumber } from './src/number/parseNumber.js'; export { parseNumber } from './src/number/parseNumber.js';

View file

@ -1,4 +1,5 @@
import { emptyStringWhenNumberNan } from './utils/emptyStringWhenNumberNan.js'; import { emptyStringWhenNumberNan } from './utils/emptyStringWhenNumberNan.js';
import { getSeparatorsFromNumber } from './getSeparatorsFromNumber.js';
import { getDecimalSeparator } from './getDecimalSeparator.js'; import { getDecimalSeparator } from './getDecimalSeparator.js';
import { getGroupSeparator } from './getGroupSeparator.js'; import { getGroupSeparator } from './getGroupSeparator.js';
import { getLocale } from '../utils/getLocale.js'; import { getLocale } from '../utils/getLocale.js';
@ -48,14 +49,29 @@ export function formatNumberToParts(number, options = {}) {
let formattedParts = []; let formattedParts = [];
const formattedNumber = Intl.NumberFormat(computedLocale, options).format(parsedNumber); const formattedNumber = Intl.NumberFormat(computedLocale, options).format(parsedNumber);
const regexCurrency = /[.,\s0-9]/; const { decimalSeparator, groupSeparator } = getSeparatorsFromNumber(
parsedNumber,
formattedNumber,
options,
);
// eslint-disable-next-line no-irregular-whitespace
const regexCurrency = /[.,\s0-9_ ]/;
const regexMinusSign = /[-]/; // U+002D, Hyphen-Minus, &#45; const regexMinusSign = /[-]/; // U+002D, Hyphen-Minus, &#45;
const regexNum = /[0-9]/; const regexNum = /[0-9]/;
const regexSeparator = /[.,]/;
const regexSpace = /[\s]/; const regexSpace = /[\s]/;
let currency = ''; let currency = '';
let numberPart = ''; let numberPart = '';
let fraction = false; let fraction = false;
let isGroup = false;
const group = getGroupSeparator(computedLocale, options);
const decimal = getDecimalSeparator(computedLocale, options);
if (decimalSeparator && groupSeparator && group === decimal) {
throw new Error(`Decimal and group (thousand) separator are the same character: '${group}'.
This can happen due to both props being specified as the same, or one of the props being the same as the other one from default locale.
Please specify .groupSeparator / .decimalSeparator on the formatOptions object to be different.`);
}
for (let i = 0; i < formattedNumber.length; i += 1) { for (let i = 0; i < formattedNumber.length; i += 1) {
// detect minusSign // detect minusSign
if (regexMinusSign.test(formattedNumber[i])) { if (regexMinusSign.test(formattedNumber[i])) {
@ -76,24 +92,35 @@ export function formatNumberToParts(number, options = {}) {
currency = ''; currency = '';
} }
// detect dot and comma separators // group sep must be lead by / followed by a number
if (regexSeparator.test(formattedNumber[i])) { if (
formattedNumber[i] === groupSeparator &&
formattedNumber[i - 1].match(regexNum) &&
formattedNumber[i + 1].match(regexNum)
) {
// Write number grouping // Write number grouping
if (numberPart) { if (numberPart) {
formattedParts.push({ type: 'integer', value: numberPart }); formattedParts.push({ type: 'integer', value: numberPart });
numberPart = ''; numberPart = '';
} }
const decimal = getDecimalSeparator(computedLocale, options);
if (formattedNumber[i] === decimal || options.decimalSeparator === decimal) { formattedParts.push({ type: 'group', value: group });
formattedParts.push({ type: 'decimal', value: decimal }); isGroup = true;
fraction = true;
} else {
formattedParts.push({ type: 'group', value: formattedNumber[i] });
}
} }
if (formattedNumber[i] === decimalSeparator) {
// Write number grouping
if (numberPart) {
formattedParts.push({ type: 'integer', value: numberPart });
numberPart = '';
}
formattedParts.push({ type: 'decimal', value: decimal });
fraction = true;
}
// detect literals (empty spaces) or space group separator // detect literals (empty spaces) or space group separator
if (regexSpace.test(formattedNumber[i])) { if (regexSpace.test(formattedNumber[i])) {
const group = getGroupSeparator(computedLocale);
const hasNumberPart = !!numberPart; const hasNumberPart = !!numberPart;
// Write number grouping // Write number grouping
if (numberPart && !fraction) { if (numberPart && !fraction) {
@ -106,10 +133,12 @@ export function formatNumberToParts(number, options = {}) {
// If space equals the group separator it gets type group // If space equals the group separator it gets type group
if (normalSpaces(formattedNumber[i]) === group && hasNumberPart && !fraction) { if (normalSpaces(formattedNumber[i]) === group && hasNumberPart && !fraction) {
formattedParts.push({ type: 'group', value: formattedNumber[i] }); formattedParts.push({ type: 'group', value: formattedNumber[i] });
} else { // if we already pushed it as a group separator, don't add it as a literal on top..
} else if (!isGroup) {
formattedParts.push({ type: 'literal', value: formattedNumber[i] }); formattedParts.push({ type: 'literal', value: formattedNumber[i] });
} }
} }
isGroup = false;
// Numbers after the decimal sign are fractions, write the last // Numbers after the decimal sign are fractions, write the last
// fractions at the end of the number // fractions at the end of the number
if (fraction === true && i === formattedNumber.length - 1) { if (fraction === true && i === formattedNumber.length - 1) {

View file

@ -5,9 +5,13 @@ import { normalSpaces } from './utils/normalSpaces.js';
* Gets the group separator * Gets the group separator
* *
* @param {string} [locale] To override the browser locale * @param {string} [locale] To override the browser locale
* @param {import('../../types/LocalizeMixinTypes').FormatNumberOptions} [options]
* @returns {string} * @returns {string}
*/ */
export function getGroupSeparator(locale) { export function getGroupSeparator(locale, options) {
if (options && options.groupSeparator) {
return options.groupSeparator;
}
const computedLocale = getLocale(locale); const computedLocale = getLocale(locale);
const formattedNumber = Intl.NumberFormat(computedLocale, { const formattedNumber = Intl.NumberFormat(computedLocale, {
style: 'decimal', style: 'decimal',

View file

@ -0,0 +1,58 @@
/**
*
* @param {number} parsedNumber
* @param {string} formattedNumber
* @param {import('../../types/LocalizeMixinTypes').FormatNumberOptions} [options]
* @returns {{groupSeparator: string|null, decimalSeparator: string|null}}
*/
export function getSeparatorsFromNumber(parsedNumber, formattedNumber, options) {
// separator can only happen if there is at least 1 digit before and after the separator
// eslint-disable-next-line no-irregular-whitespace
const regexSeparator = /[0-9](?<sep>[\s,._ '])[0-9]/g;
/** @type {string[]} */
const separators = [];
let match;
// eslint-disable-next-line no-cond-assign
while ((match = regexSeparator.exec(formattedNumber)) !== null) {
if (match.groups && match.groups.sep) {
separators.push(match.groups?.sep);
}
}
let groupSeparator = null;
let decimalSeparator = null;
if (separators) {
if (separators.length === 1) {
const parts = formattedNumber.split(separators[0]);
// Not sure if decimal or group, because only 1 separator.
// if the separator is followed by at least 3 or more digits
// and if the original number value is more or equal than 1000 or less or equal than -1000
// or the minimum integer digits is forced to more than 3,
// it has to be the group separator
if (
parts[1].replace(/[^0-9]/g, '').length >= 3 &&
(parsedNumber >= 1000 ||
parsedNumber <= -1 * 1000 ||
(options?.minimumIntegerDigits && options.minimumIntegerDigits > 3))
) {
[groupSeparator] = separators;
} else {
[decimalSeparator] = separators;
}
} else if (separators.every(val => val === separators[0])) {
// multiple separators, check if they are all the same or not
// if the same, it means they are group separators
// if not, it means that the last one must be the decimal separator
[groupSeparator] = separators;
} else {
[groupSeparator] = separators;
decimalSeparator = separators[separators.length - 1];
}
}
return {
groupSeparator,
decimalSeparator,
};
}

View file

@ -94,7 +94,7 @@ function parseHeuristic(value) {
// 1. put placeholder at decimal separator // 1. put placeholder at decimal separator
const numberString = value const numberString = value
.replace(/(,|\.)([^,|.]*)$/g, '_decSep_$2') .replace(/(,|\.)([^,|.]*)$/g, '_decSep_$2')
.replace(/(,|\.| )/g, '') // 2. remove all thousand separators .replace(/(,|\.| )/g, '') // 2. remove all group separators
.replace(/_decSep_/, '.'); // 3. restore decimal separator .replace(/_decSep_/, '.'); // 3. restore decimal separator
return parseFloat(numberString); return parseFloat(numberString);
} }

View file

@ -179,6 +179,64 @@ describe('formatNumber', () => {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}), }),
).to.equal('112.345.678,00'); ).to.equal('112.345.678,00');
expect(
formatNumber(112345678, {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
groupSeparator: ' ',
decimalSeparator: '.',
}),
).to.equal('112 345 678.00');
});
it('throws when decimal and group separator are the same value, only when problematic', () => {
localize.locale = 'nl-NL';
const fn = () =>
formatNumber(112345678, {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
decimalSeparator: '.', // same as group separator for nl-NL
});
expect(fn).to.throw(`Decimal and group (thousand) separator are the same character: '.'.
This can happen due to both props being specified as the same, or one of the props being the same as the other one from default locale.
Please specify .groupSeparator / .decimalSeparator on the formatOptions object to be different.`);
const fn2 = () =>
formatNumber(112345678, {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
groupSeparator: ',',
decimalSeparator: ',',
});
expect(fn2).to.throw(`Decimal and group (thousand) separator are the same character: ','.
This can happen due to both props being specified as the same, or one of the props being the same as the other one from default locale.
Please specify .groupSeparator / .decimalSeparator on the formatOptions object to be different.`);
// this one doesn't end up with decimals, so not a problem
const fn3 = () =>
formatNumber(112345678, {
groupSeparator: ',',
decimalSeparator: ',',
});
expect(fn3).to.not.throw();
// this one doesn't end up with group separators (<1000), so not a problem
const fn4 = () =>
formatNumber(112.345678, {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
groupSeparator: ',',
decimalSeparator: ',',
});
expect(fn4).to.not.throw();
}); });
it('formats 2-digit decimals correctly', () => { it('formats 2-digit decimals correctly', () => {

View file

@ -0,0 +1,48 @@
import { expect } from '@open-wc/testing';
import { getSeparatorsFromNumber } from '../../src/number/getSeparatorsFromNumber.js';
describe('getSeparatorsFromNumber', () => {
it('returns group separator for locale', () => {
expect(getSeparatorsFromNumber(99, '99.00')).to.eql({
groupSeparator: null,
decimalSeparator: '.',
});
expect(getSeparatorsFromNumber(1000, '1,000')).to.eql({
groupSeparator: ',',
decimalSeparator: null,
});
expect(getSeparatorsFromNumber(12345678901, '12,345,678.901')).to.eql({
groupSeparator: ',',
decimalSeparator: '.',
});
expect(getSeparatorsFromNumber(12345678901, '12_345_678_901')).to.eql({
groupSeparator: '_',
decimalSeparator: null,
});
expect(getSeparatorsFromNumber(123, '123,00 €')).to.eql({
groupSeparator: null,
decimalSeparator: ',',
});
expect(getSeparatorsFromNumber(123, '€123,00')).to.eql({
groupSeparator: null,
decimalSeparator: ',',
});
expect(getSeparatorsFromNumber(1234, '123.400 dollar')).to.eql({
groupSeparator: '.',
decimalSeparator: null,
});
expect(getSeparatorsFromNumber(1234.5, '1 234,50 €')).to.eql({
groupSeparator: ' ',
decimalSeparator: ',',
});
expect(getSeparatorsFromNumber(-1234, '-1,234')).to.eql({
groupSeparator: ',',
decimalSeparator: null,
});
expect(getSeparatorsFromNumber(123, '0,123', { minimumIntegerDigits: 4 })).to.eql({
groupSeparator: ',',
decimalSeparator: null,
});
});
});

View file

@ -19,7 +19,6 @@ export declare interface FormatDateOptions extends Intl.DateTimeFormatOptions {
roundMode?: string; roundMode?: string;
returnIfNaN?: string; returnIfNaN?: string;
decimalSeparator?: string;
mode?: 'pasted' | 'auto'; mode?: 'pasted' | 'auto';
postProcessors?: Map<string, DatePostProcessor>; postProcessors?: Map<string, DatePostProcessor>;
@ -35,7 +34,11 @@ export declare interface FormatNumberOptions extends Intl.NumberFormatOptions {
numberingSystem?: string; numberingSystem?: string;
roundMode?: string; roundMode?: string;
returnIfNaN?: string; returnIfNaN?: string;
decimalSeparator?: string; // https://en.wikipedia.org/wiki/Decimal_separator#Current_standards
decimalSeparator?: ',' | '.';
// https://en.wikipedia.org/wiki/Decimal_separator#Digit_grouping
// note the half space in there as well
groupSeparator?: ',' | '.' | '' | '_' | ' ' | "'";
mode?: 'pasted' | 'auto'; mode?: 'pasted' | 'auto';
postProcessors?: Map<string, NumberPostProcessor>; postProcessors?: Map<string, NumberPostProcessor>;