UNPKG

@wareme/currency-input

Version:

Easily input currencies in Dark applications

524 lines (443 loc) 14.2 kB
import { detectIsArray, detectIsEmpty, detectIsNull, detectIsNumber, detectIsString, detectIsUndefined, illegal } from '@dark-engine/core' export const throwError = (msg) => illegal(msg, 'currency-input') export const isNumber = (input) => /\d/gi.test(input) export const addSeparators = (value/*: string */, separator = ',')/*: string */ => { return value.replace(/\B(?=(\d{3})+(?!\d))/g, separator) } // https://stackoverflow.com/questions/17885855/use-dynamic-variable-string-as-regex-pattern-in-javascript export const escapeRegExp = (stringToGoIntoTheRegex/*: string */)/*: string */ => { return stringToGoIntoTheRegex.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') } export const removeSeparators = (value/*: string */, separator = ',')/*: string */ => { const reg = new RegExp(escapeRegExp(separator), 'g') return value.replace(reg, '') } export const removeInvalidChars = (value/*: string */, validChars/*: Array<string> */)/*: string */ => { const chars = escapeRegExp(validChars.join('')) const reg = new RegExp(`[^\\d${chars}]`, 'gi') return value.replace(reg, '') } // https://stackoverflow.com/a/9345181 export const abbrValue = (value/*: number */, decimalSeparator = '.', _decimalPlaces = 10)/*: string */ => { if (value > 999) { let valueLength = ('' + value).length const p = Math.pow const d = p(10, _decimalPlaces) valueLength -= valueLength % 3 const abbrValue = Math.round((value * d) / p(10, valueLength)) / d + ' kMGTPE'[valueLength / 3] return abbrValue.replace('.', decimalSeparator) } return String(value) } const abbreviationsMap = { k: 1_000, m: 1_000_000, b: 1_000_000_000 } /** * Parse a value with abbreviation e.g 1k = 1000 */ export const parseAbbrValue = (value/*: string */, decimalSeparator = '.')/*: string | null */ => { const reg = new RegExp(`(\\d+(${escapeRegExp(decimalSeparator)}\\d*)?)([kmb])$`, 'i') const match = value.match(reg) if (match) { const [, digits, , abbr] = match const multiplier = abbreviationsMap[abbr.toLowerCase()] const result = Number(digits.replace(decimalSeparator, '.')) * multiplier return String(result) } return null } /** * Remove prefix, separators and extra decimals from value */ export const cleanValue = ({ value, groupSeparator = ',', decimalSeparator = '.', allowDecimals = true, decimalsLimit = 2, allowNegativeValue = true, disableAbbreviations = false, prefix = '', transformRawValue = (rawValue) => rawValue }) => { const transformedValue = transformRawValue(value) if (transformedValue === '-') { return transformedValue } const getAbbreviations = () => { if (disableAbbreviations) { return [] } return ['k', 'm', 'b'] } const abbreviations = getAbbreviations() const reg = new RegExp(`((^|\\D)-\\d)|(-${escapeRegExp(prefix)})`) const isNegative = reg.test(transformedValue) // Is there a digit before the prefix? eg. 1$ const [prefixWithValue, preValue] = RegExp(`(\\d+)-?${escapeRegExp(prefix)}`).exec(value) || [] const getWithoutPrefix = () => { if (prefix) { if (prefixWithValue) { return transformedValue.replace(prefixWithValue, '').concat(preValue) } return transformedValue.replace(prefix, '') } return transformedValue } const withoutPrefix = getWithoutPrefix() const withoutSeparators = removeSeparators(withoutPrefix, groupSeparator) const withoutInvalidChars = removeInvalidChars(withoutSeparators, [ groupSeparator, decimalSeparator, ...abbreviations ]) let valueOnly = withoutInvalidChars if (!disableAbbreviations) { // disallow letter without number if ( abbreviations.some( (letter) => letter === withoutInvalidChars.toLowerCase().replace(decimalSeparator, '') ) ) { return '' } const parsed = parseAbbrValue(withoutInvalidChars, decimalSeparator) if (detectIsString(parsed)) { valueOnly = parsed } } const getIncludeNegative = () => { if (isNegative && allowNegativeValue) { return '-' } return '' } const includeNegative = getIncludeNegative() if (decimalSeparator && valueOnly.includes(decimalSeparator)) { const [int, decimals] = withoutInvalidChars.split(decimalSeparator) const trimmedDecimals = decimalsLimit && decimals ? decimals.slice(0, decimalsLimit) : decimals const includeDecimals = allowDecimals ? `${decimalSeparator}${trimmedDecimals}` : '' return `${includeNegative}${int}${includeDecimals}` } return `${includeNegative}${valueOnly}` } export const fixedDecimalValue = ( value/*: string */, decimalSeparator/*: string */, fixedDecimalLength/*: number */ )/*: string */ => { if (detectIsNumber(fixedDecimalLength) && value.length > 1) { if (fixedDecimalLength === 0) { return value.replace(decimalSeparator, '') } if (value.includes(decimalSeparator)) { const [int, decimals] = value.split(decimalSeparator) if (decimals.length === fixedDecimalLength) { return value } if (decimals.length > fixedDecimalLength) { return `${int}${decimalSeparator}${decimals.slice(0, fixedDecimalLength)}` } } const getRegex = () => { if (value.length > fixedDecimalLength) { return new RegExp(`(\\d+)(\\d{${fixedDecimalLength}})`) } return /(\d)(\d+)/ } const regex = getRegex() const match = value.match(regex) if (match) { const [, int, decimals] = match return `${int}${decimalSeparator}${decimals}` } } return value } export const getSuffix = ( value/*: string */, { groupSeparator = ',', decimalSeparator = '.' } ) => { const suffixRegex = new RegExp( `\\d([^${escapeRegExp(groupSeparator)}${escapeRegExp(decimalSeparator)}0-9]+)` ) const suffixMatch = value.match(suffixRegex) if (detectIsArray(suffixMatch)) { return suffixMatch[1] } return null } const defaultConfig = { currencySymbol: '', groupSeparator: '', decimalSeparator: '', prefix: '', suffix: '' } /** * Get locale config from input or default */ export const getLocaleConfig = (intlConfig) => { const { locale, currency } = intlConfig || {} const getNumberFormatter = () => { if (locale) { if (currency) { return new Intl.NumberFormat(locale, { currency, style: 'currency' }) } return new Intl.NumberFormat(locale) } return new Intl.NumberFormat() } const numberFormatter = getNumberFormatter() return numberFormatter.formatToParts(1000.1).reduce((prev, curr, i) => { if (curr.type === 'currency') { if (i === 0) { return { ...prev, currencySymbol: curr.value, prefix: curr.value } } else { return { ...prev, currencySymbol: curr.value, suffix: curr.value } } } if (curr.type === 'group') { return { ...prev, groupSeparator: curr.value } } if (curr.type === 'decimal') { return { ...prev, decimalSeparator: curr.value } } return prev }, defaultConfig) } /** * Format value with decimal separator, group separator and prefix */ export const formatValue = (options)/*: string */ => { const { value: _value, decimalSeparator, intlConfig, decimalScale, prefix, suffix = '' } = options if (_value === '' || detectIsUndefined(_value)) { return '' } if (_value === '-') { return '-' } const getPrefix = () => { if (detectIsEmpty(prefix)) { return '' } return `${escapeRegExp(prefix)}?` } const isNegative = new RegExp(`^\\d?-${getPrefix()}\\d`).test(_value) // replace custom decimal separator if needed const getValueWithoutDecimalSeparator = () => { if (decimalSeparator !== '.') { return replaceDecimalSeparator(_value, decimalSeparator, isNegative) } return _value } const valueWithoutDecimalSeparator = getValueWithoutDecimalSeparator() // add leading zero if needed const getValueWithLeadingZero = () => { if (decimalSeparator && decimalSeparator !== '-' && valueWithoutDecimalSeparator.startsWith(decimalSeparator)) { return `0${valueWithoutDecimalSeparator}` } return valueWithoutDecimalSeparator } const value = getValueWithLeadingZero() const defaultNumberFormatOptions = { minimumFractionDigits: decimalScale || 0, maximumFractionDigits: 20 } const numberFormatter = intlConfig ? new Intl.NumberFormat( intlConfig.locale, intlConfig.currency ? { ...defaultNumberFormatOptions, style: 'currency', currency: intlConfig.currency } : defaultNumberFormatOptions ) : new Intl.NumberFormat(undefined, defaultNumberFormatOptions) const parts = numberFormatter.formatToParts(Number(value)) let formatted = replaceParts(parts, options) // Does intl formatting add a suffix? const intlSuffix = getSuffix(formatted, { ...options }) // Include decimal separator if user input ends with decimal separator const includeDecimalSeparator = _value.slice(-1) === decimalSeparator ? decimalSeparator : '' const getDecimals = () => { const regex = /\d+\.(\d+)/ const matches = value.match(regex) if (detectIsArray(matches) && matches.length > 1) { const decimals = matches[1] return decimals } return null } const decimals = getDecimals() // Keep original decimal padding if no decimalScale if (detectIsEmpty(decimalScale) && decimals && decimalSeparator) { if (formatted.includes(decimalSeparator)) { formatted = formatted.replace( RegExp(`(\\d+)(${escapeRegExp(decimalSeparator)})(\\d+)`, 'g'), `$1$2${decimals}` ) } else { if (intlSuffix && !suffix) { formatted = formatted.replace(intlSuffix, `${decimalSeparator}${decimals}${intlSuffix}`) } else { formatted = `${formatted}${decimalSeparator}${decimals}` } } } if (suffix && includeDecimalSeparator) { return `${formatted}${includeDecimalSeparator}${suffix}` } if (intlSuffix && includeDecimalSeparator) { return formatted.replace(intlSuffix, `${includeDecimalSeparator}${intlSuffix}`) } if (intlSuffix && suffix) { return formatted.replace(intlSuffix, `${includeDecimalSeparator}${suffix}`) } return [formatted, includeDecimalSeparator, suffix].join('') } /** * Before converting to Number, decimal separator has to be . */ const replaceDecimalSeparator = ( value/*: string */, decimalSeparator, isNegative/*: boolean */ )/*: string */ => { if (decimalSeparator && decimalSeparator !== '.') { const newValue = value.replace(RegExp(escapeRegExp(decimalSeparator), 'g'), '.') if (isNegative && decimalSeparator === '-') { return `-${newValue.slice(1)}` } return newValue } return value } const replaceParts = ( parts, { prefix, groupSeparator, decimalSeparator, decimalScale, disableGroupSeparators = false })/*: string */ => { return parts .reduce( (prev, { type, value }, i) => { if (i === 0 && prefix) { if (type === 'minusSign') { return [value, prefix] } if (type === 'currency') { return [...prev, prefix] } return [prefix, value] } if (type === 'currency') { if (prefix) { return prev } return [...prev, value] } if (type === 'group') { if (!disableGroupSeparators) { if (groupSeparator) { return [...prev, groupSeparator] } return [...prev, value] } return prev } if (type === 'decimal') { if (decimalScale && decimalScale === 0) { return prev } if (decimalSeparator) { return [...prev, decimalSeparator] } return [...prev, value] } if (type === 'fraction') { if (decimalScale) { return [...prev, value.slice(0, decimalScale)] } return [...prev, value] } return [...prev, value] }, [''] ) .join('') } export const padTrimValue = ( value/*: string | undefined */, decimalSeparator = '.', decimalScale/*: number | undefined */ )/*: string */ => { if (detectIsUndefined(decimalScale) || value === '' || detectIsUndefined(value)) { return value } const matches = value.match(/\d/g) if (detectIsNull(matches)) { return '' } const [int, decimals] = value.split(decimalSeparator) if (decimalScale === 0) { return int } let newValue = decimals || '' if (newValue.length < decimalScale) { while (newValue.length < decimalScale) { newValue += '0' } } else { newValue = newValue.slice(0, decimalScale) } return `${int}${decimalSeparator}${newValue}` } export const repositionCursor = ({ selectionStart/*: number | null | undefined */, value/*: number */, lastKeyStroke/*: string | null */, stateValue/*: string | undefined */, groupSeparator/*: string | undefined */ }) /* { modifiedValue: string cursorPosition: number | null | undefined} */ => { let cursorPosition = selectionStart let modifiedValue = value if (stateValue && cursorPosition) { const splitValue = value.split('') // if cursor is to right of groupSeparator and backspace pressed, delete the character to the left of the separator and reposition the cursor if (lastKeyStroke === 'Backspace' && stateValue[cursorPosition] === groupSeparator) { splitValue.splice(cursorPosition - 1, 1) cursorPosition -= 1 } // if cursor is to left of groupSeparator and delete pressed, delete the character to the right of the separator and reposition the cursor if (lastKeyStroke === 'Delete' && stateValue[cursorPosition] === groupSeparator) { splitValue.splice(cursorPosition, 1) cursorPosition += 1 } modifiedValue = splitValue.join('') return { modifiedValue, cursorPosition } } return { modifiedValue, cursorPosition: selectionStart } }