feat: format number add thousandSeparator option, fix type decimalSep (#1774)
This commit is contained in:
parent
11c5ffe094
commit
2d58320e51
12 changed files with 240 additions and 21 deletions
6
.changeset/neat-pots-prove.md
Normal file
6
.changeset/neat-pots-prove.md
Normal 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 '.'.
|
||||
|
|
@ -108,15 +108,15 @@ export const noDecimals = () => html`
|
|||
|
||||
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.
|
||||
Therefore, we use the heuristics based method to parse the input when it is pasted by the user.
|
||||
|
||||
### What this means
|
||||
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export const unparseable = () => html`
|
|||
|
||||
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,18 @@ describe('<lion-input-amount>', () => {
|
|||
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 () => {
|
||||
const el = /** @type {LionInputAmount} */ (
|
||||
await fixture(html`
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@ export { getCurrencyName } from './src/number/getCurrencyName.js';
|
|||
export { getDecimalSeparator } from './src/number/getDecimalSeparator.js';
|
||||
export { getFractionDigits } from './src/number/getFractionDigits.js';
|
||||
export { getGroupSeparator } from './src/number/getGroupSeparator.js';
|
||||
export { getSeparatorsFromNumber } from './src/number/getSeparatorsFromNumber.js';
|
||||
export { normalizeCurrencyLabel } from './src/number/normalizeCurrencyLabel.js';
|
||||
export { parseNumber } from './src/number/parseNumber.js';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { emptyStringWhenNumberNan } from './utils/emptyStringWhenNumberNan.js';
|
||||
import { getSeparatorsFromNumber } from './getSeparatorsFromNumber.js';
|
||||
import { getDecimalSeparator } from './getDecimalSeparator.js';
|
||||
import { getGroupSeparator } from './getGroupSeparator.js';
|
||||
import { getLocale } from '../utils/getLocale.js';
|
||||
|
|
@ -48,14 +49,29 @@ export function formatNumberToParts(number, options = {}) {
|
|||
let formattedParts = [];
|
||||
|
||||
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, -
|
||||
const regexNum = /[0-9]/;
|
||||
const regexSeparator = /[.,]/;
|
||||
const regexSpace = /[\s]/;
|
||||
let currency = '';
|
||||
let numberPart = '';
|
||||
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) {
|
||||
// detect minusSign
|
||||
if (regexMinusSign.test(formattedNumber[i])) {
|
||||
|
|
@ -76,24 +92,35 @@ export function formatNumberToParts(number, options = {}) {
|
|||
currency = '';
|
||||
}
|
||||
|
||||
// detect dot and comma separators
|
||||
if (regexSeparator.test(formattedNumber[i])) {
|
||||
// group sep must be lead by / followed by a number
|
||||
if (
|
||||
formattedNumber[i] === groupSeparator &&
|
||||
formattedNumber[i - 1].match(regexNum) &&
|
||||
formattedNumber[i + 1].match(regexNum)
|
||||
) {
|
||||
// Write number grouping
|
||||
if (numberPart) {
|
||||
formattedParts.push({ type: 'integer', value: numberPart });
|
||||
numberPart = '';
|
||||
}
|
||||
const decimal = getDecimalSeparator(computedLocale, options);
|
||||
if (formattedNumber[i] === decimal || options.decimalSeparator === decimal) {
|
||||
formattedParts.push({ type: 'decimal', value: decimal });
|
||||
fraction = true;
|
||||
} else {
|
||||
formattedParts.push({ type: 'group', value: formattedNumber[i] });
|
||||
}
|
||||
|
||||
formattedParts.push({ type: 'group', value: group });
|
||||
isGroup = true;
|
||||
}
|
||||
|
||||
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
|
||||
if (regexSpace.test(formattedNumber[i])) {
|
||||
const group = getGroupSeparator(computedLocale);
|
||||
const hasNumberPart = !!numberPart;
|
||||
// Write number grouping
|
||||
if (numberPart && !fraction) {
|
||||
|
|
@ -106,10 +133,12 @@ export function formatNumberToParts(number, options = {}) {
|
|||
// If space equals the group separator it gets type group
|
||||
if (normalSpaces(formattedNumber[i]) === group && hasNumberPart && !fraction) {
|
||||
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] });
|
||||
}
|
||||
}
|
||||
isGroup = false;
|
||||
// Numbers after the decimal sign are fractions, write the last
|
||||
// fractions at the end of the number
|
||||
if (fraction === true && i === formattedNumber.length - 1) {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@ import { normalSpaces } from './utils/normalSpaces.js';
|
|||
* Gets the group separator
|
||||
*
|
||||
* @param {string} [locale] To override the browser locale
|
||||
* @param {import('../../types/LocalizeMixinTypes').FormatNumberOptions} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getGroupSeparator(locale) {
|
||||
export function getGroupSeparator(locale, options) {
|
||||
if (options && options.groupSeparator) {
|
||||
return options.groupSeparator;
|
||||
}
|
||||
const computedLocale = getLocale(locale);
|
||||
const formattedNumber = Intl.NumberFormat(computedLocale, {
|
||||
style: 'decimal',
|
||||
|
|
|
|||
58
packages/localize/src/number/getSeparatorsFromNumber.js
Normal file
58
packages/localize/src/number/getSeparatorsFromNumber.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -94,7 +94,7 @@ function parseHeuristic(value) {
|
|||
// 1. put placeholder at decimal separator
|
||||
const numberString = value
|
||||
.replace(/(,|\.)([^,|.]*)$/g, '_decSep_$2')
|
||||
.replace(/(,|\.| )/g, '') // 2. remove all thousand separators
|
||||
.replace(/(,|\.| )/g, '') // 2. remove all group separators
|
||||
.replace(/_decSep_/, '.'); // 3. restore decimal separator
|
||||
return parseFloat(numberString);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,6 +179,64 @@ describe('formatNumber', () => {
|
|||
maximumFractionDigits: 2,
|
||||
}),
|
||||
).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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -19,7 +19,6 @@ export declare interface FormatDateOptions extends Intl.DateTimeFormatOptions {
|
|||
|
||||
roundMode?: string;
|
||||
returnIfNaN?: string;
|
||||
decimalSeparator?: string;
|
||||
mode?: 'pasted' | 'auto';
|
||||
|
||||
postProcessors?: Map<string, DatePostProcessor>;
|
||||
|
|
@ -35,7 +34,11 @@ export declare interface FormatNumberOptions extends Intl.NumberFormatOptions {
|
|||
numberingSystem?: string;
|
||||
roundMode?: 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';
|
||||
|
||||
postProcessors?: Map<string, NumberPostProcessor>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue