lion/packages/localize/src/formatNumber.js
Thomas Allmer ec8da8f12c feat: release inital public lion version
Co-authored-by: Mikhail Bashkirov <mikhail.bashkirov@ing.com>
Co-authored-by: Thijs Louisse <thijs.louisse@ing.com>
Co-authored-by: Joren Broekema <joren.broekema@ing.com>
Co-authored-by: Gerjan van Geest <gerjan.van.geest@ing.com>
Co-authored-by: Erik Kroes <erik.kroes@ing.com>
Co-authored-by: Lars den Bakker <lars.den.bakker@ing.com>
2019-04-26 10:37:57 +02:00

403 lines
13 KiB
JavaScript

import { localize } from './localize.js';
/**
* Gets the locale to use
*
* @param {string} locale Locale to override browser locale
* @returns {string}
*/
function getLocale(locale) {
if (locale) {
return locale;
}
if (localize && localize.locale) {
return localize.locale;
}
return 'en-GB';
}
/**
* Round the number based on the options
*
* @param {number} number
* @param {string} roundMode
* @returns {*}
*/
function roundNumber(number, roundMode) {
switch (roundMode) {
case 'floor':
return Math.floor(number);
case 'ceiling':
return Math.ceil(number);
case 'round':
return Math.round(number);
default:
throw new Error('roundMode can only be round|floor|ceiling');
}
}
/**
* @param {Array} value
* @return {Array} value with forced "normal" space
*/
export function normalSpaces(value) {
// If non-breaking space (160) or narrow non-breaking space (8239) then return ' '
return value.charCodeAt(0) === 160 || value.charCodeAt(0) === 8239 ? ' ' : value;
}
/**
* To get the group separator
*
* @param {string} locale To override the browser locale
* @returns {Object} the separator
*/
export function getGroupSeparator(locale) {
const computedLocale = getLocale(locale);
const formattedNumber = Intl.NumberFormat(computedLocale, {
style: 'decimal',
minimumFractionDigits: 0,
}).format('1000');
return normalSpaces(formattedNumber[1]);
}
/**
* To get the decimal separator
*
* @param {string} locale To override the browser locale
* @returns {Object} the separator
*/
export function getDecimalSeparator(locale) {
const computedLocale = getLocale(locale);
const formattedNumber = Intl.NumberFormat(computedLocale, {
style: 'decimal',
minimumFractionDigits: 1,
}).format('1');
return formattedNumber[1];
}
/**
* When number is NaN we should return an empty string or returnIfNaN param
*
* @param {string} returnIfNaN
* @returns {*}
*/
function emptyStringWhenNumberNan(returnIfNaN) {
const stringToReturn = returnIfNaN || localize.formatNumberOptions.returnIfNaN;
return stringToReturn;
}
/**
* For Dutch and Belgian amounts the currency should be at the end of the string
*
* @param {Array} formattedParts
* @returns {Array}
*/
function forceCurrencyToEnd(formattedParts) {
if (formattedParts[0].type === 'currency') {
const moveCur = formattedParts.splice(0, 1);
const moveLit = formattedParts.splice(0, 1);
formattedParts.push(moveLit[0]);
formattedParts.push(moveCur[0]);
} else if (formattedParts[0].type === 'minusSign' && formattedParts[1].type === 'currency') {
const moveCur = formattedParts.splice(1, 1);
const moveLit = formattedParts.splice(1, 1);
formattedParts.push(moveLit[0]);
formattedParts.push(moveCur[0]);
}
return formattedParts;
}
/**
* When in some locales there is no space between currency and amount it is added
*
* @param {Array} formattedParts
* @param {Object} options
* @returns {*}
*/
function forceSpaceBetweenCurrencyCodeAndNumber(formattedParts, options) {
const numberOfParts = formattedParts.length;
const literalObject = { type: 'literal', value: ' ' };
if (numberOfParts > 1 && options && options.currency && options.currencyDisplay === 'code') {
if (formattedParts[0].type === 'currency' && formattedParts[1].type !== 'literal') {
// currency in front of a number: EUR 1.00
formattedParts.splice(1, 0, literalObject);
} else if (
formattedParts[0].type === 'minusSign' &&
formattedParts[1].type === 'currency' &&
formattedParts[2].type !== 'literal'
) {
// currency in front of a negative number: -EUR 1.00
formattedParts.splice(2, 0, literalObject);
} else if (
formattedParts[numberOfParts - 1].type === 'currency' &&
formattedParts[numberOfParts - 2].type !== 'literal'
) {
// currency in behind a number: 1.00 EUR || -1.00 EUR
formattedParts.splice(numberOfParts - 1, 0, literalObject);
}
}
return formattedParts;
}
/**
* Add separators when they are not present
*
* @param {Array} formattedParts
* @param {string} groupSeparator
* @returns {Array}
*/
function forceAddGroupSeparators(formattedParts, groupSeparator) {
let concatArray = [];
if (formattedParts[0].type === 'integer') {
const getInteger = formattedParts.splice(0, 1);
const numberOfDigits = getInteger[0].value.length;
const mod3 = numberOfDigits % 3;
const groups = Math.floor(numberOfDigits / 3);
const numberArray = [];
let numberOfGroups = 0;
let numberPart = '';
let firstGroup = false;
// Loop through the integer
for (let i = 0; i < numberOfDigits; i += 1) {
numberPart += getInteger[0].value[i];
// Create first grouping which is < 3
if (numberPart.length === mod3 && firstGroup === false) {
numberArray.push({ type: 'integer', value: numberPart });
if (numberOfDigits > 3) {
numberArray.push({ type: 'group', value: groupSeparator });
}
numberPart = '';
firstGroup = true;
// Create groupings of 3
} else if (numberPart.length === 3 && i < numberOfDigits - 1) {
numberOfGroups += 1;
numberArray.push({ type: 'integer', value: numberPart });
if (numberOfGroups !== groups) {
numberArray.push({ type: 'group', value: groupSeparator });
}
numberPart = '';
}
}
numberArray.push({ type: 'integer', value: numberPart });
concatArray = numberArray.concat(formattedParts);
}
return concatArray;
}
/**
* @param {Array} formattedParts
* @return {Array} parts with forced "normal" spaces
*/
function forceNormalSpaces(formattedParts) {
const result = [];
formattedParts.forEach(part => {
result.push({
type: part.type,
value: normalSpaces(part.value),
});
});
return result;
}
function forceYenSymbol(formattedParts, options) {
const result = formattedParts;
const numberOfParts = result.length;
// Change the symbol from JPY to ¥, due to bug in Chrome
if (
numberOfParts > 1 &&
options &&
options.currency === 'JPY' &&
options.currencyDisplay === 'symbol'
) {
result[numberOfParts - 1].value = '¥';
}
return result;
}
/**
* Function with all fixes on localize
*
* @param {Array} formattedParts
* @param {Object} options
* @param {string} _locale
* @returns {*}
*/
function normalizeIntl(formattedParts, options, _locale) {
let normalize = forceNormalSpaces(formattedParts, options);
// Dutch and Belgian currency must be moved to end of number
if (options && options.style === 'currency') {
if (_locale === 'nl-NL' || _locale.slice(-2) === 'BE') {
normalize = forceCurrencyToEnd(normalize);
}
// Add group separator for Bulgarian locale
if (_locale === 'bg-BG') {
normalize = forceAddGroupSeparators(normalize, getGroupSeparator());
}
// Force space between currency code and number
if (_locale === 'en-GB' || _locale === 'en-US' || _locale === 'en-AU') {
normalize = forceSpaceBetweenCurrencyCodeAndNumber(normalize, options);
}
// Force missing Japanese Yen symbol
if (_locale === 'fr-FR' || _locale === 'fr-BE') {
normalize = forceYenSymbol(normalize, options);
}
}
return normalize;
}
/**
* Splits a number up in parts for integer, fraction, group, literal, decimal and currency.
*
* @param {number} number Number to split up
* @param {Object} options Intl options are available extended by roundMode
* @returns {Array} Array with parts
*/
export function formatNumberToParts(number, options) {
let parsedNumber = typeof number === 'string' ? parseFloat(number) : number;
const computedLocale = getLocale(options && options.locale);
// when parsedNumber is not a number we should return an empty string or returnIfNaN
if (Number.isNaN(parsedNumber)) {
return emptyStringWhenNumberNan(options && options.returnIfNaN);
}
// If roundMode is given the number is rounded based upon the mode
if (options && options.roundMode) {
parsedNumber = roundNumber(number, options.roundMode);
}
let formattedParts = [];
const formattedNumber = Intl.NumberFormat(computedLocale, options).format(parsedNumber);
const regexSymbol = /[A-Z.,\s0-9]/;
const regexCode = /[A-Z]/;
const regexMinusSign = /[-]/;
const regexNum = /[0-9]/;
const regexSeparator = /[.,]/;
const regexSpace = /[\s]/;
let currencyCode = '';
let numberPart = '';
let fraction = false;
for (let i = 0; i < formattedNumber.length; i += 1) {
// detect minusSign
if (regexMinusSign.test(formattedNumber[i])) {
formattedParts.push({ type: 'minusSign', value: formattedNumber[i] });
}
// detect numbers
if (regexNum.test(formattedNumber[i])) {
numberPart += formattedNumber[i];
}
// detect currency symbol
if (!regexSymbol.test(formattedNumber[i]) && !regexMinusSign.test(formattedNumber[i])) {
// Write number grouping
if (numberPart && !fraction) {
formattedParts.push({ type: 'integer', value: numberPart });
numberPart = '';
} else if (numberPart) {
formattedParts.push({ type: 'fraction', value: numberPart });
numberPart = '';
}
formattedParts.push({ type: 'currency', value: formattedNumber[i] });
}
// detect currency code
if (regexCode.test(formattedNumber[i])) {
currencyCode += formattedNumber[i];
// Write number grouping
if (numberPart && !fraction) {
formattedParts.push({ type: 'integer', value: numberPart });
numberPart = '';
} else if (numberPart) {
formattedParts.push({ type: 'fraction', value: numberPart });
numberPart = '';
}
if (currencyCode.length === 3) {
formattedParts.push({ type: 'currency', value: currencyCode });
currencyCode = '';
}
}
// detect dot and comma separators
if (regexSeparator.test(formattedNumber[i])) {
// Write number grouping
if (numberPart) {
formattedParts.push({ type: 'integer', value: numberPart });
numberPart = '';
}
const decimal = getDecimalSeparator();
if (formattedNumber[i] === decimal) {
formattedParts.push({ type: 'decimal', value: formattedNumber[i] });
fraction = true;
} else {
formattedParts.push({ type: 'group', value: formattedNumber[i] });
}
}
// detect literals (empty spaces) or space group separator
if (regexSpace.test(formattedNumber[i])) {
const group = getGroupSeparator();
const hasNumberPart = !!numberPart;
// Write number grouping
if (numberPart && !fraction) {
formattedParts.push({ type: 'integer', value: numberPart });
numberPart = '';
} else if (numberPart) {
formattedParts.push({ type: 'fraction', value: numberPart });
numberPart = '';
}
// 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 {
formattedParts.push({ type: 'literal', value: formattedNumber[i] });
}
}
// Numbers after the decimal sign are fractions, write the last
// fractions at the end of the number
if (fraction === true && i === formattedNumber.length - 1) {
// write last number part
if (numberPart) {
formattedParts.push({ type: 'fraction', value: numberPart });
}
// If there are no fractions but we reached the end write the numberpart as integer
} else if (i === formattedNumber.length - 1 && numberPart) {
formattedParts.push({ type: 'integer', value: numberPart });
}
}
formattedParts = normalizeIntl(formattedParts, options, computedLocale);
return formattedParts;
}
/**
* @example
* getFractionDigits('JOD'); // return 3
*
* @param {string} currency Currency code e.g. EUR
* @return {number} fraction for the given currency
*/
export function getFractionDigits(currency = 'EUR') {
const parts = formatNumberToParts(123, {
style: 'currency',
currency,
});
const [fractionPart] = parts.filter(part => part.type === 'fraction');
return fractionPart ? fractionPart.value.length : 0;
}
/**
* Formats a number based on locale and options. It uses Intl for the formatting.
*
* @param {number} number Number to be formatted
* @param {Object} options Intl options are available extended by roundMode
* @returns {*} Formatted number
*/
export function formatNumber(number, options) {
if (number === undefined || number === null) return '';
const formattedToParts = formatNumberToParts(number, options);
// If number is not a number
if (
formattedToParts === (options && options.returnIfNaN) ||
formattedToParts === localize.formatNumberOptions.returnIfNaN
) {
return formattedToParts;
}
let printNumberOfParts = '';
// update numberOfParts because there may be some parts added
const numberOfParts = formattedToParts && formattedToParts.length;
for (let i = 0; i < numberOfParts; i += 1) {
printNumberOfParts += formattedToParts[i].value;
}
return printNumberOfParts;
}