ngx-currency
Version:
A very simple currency mask directive that allows using a number attribute with the ngModel.
681 lines (671 loc) • 27.2 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, forwardRef, Directive, Optional, Inject, Input, HostListener, makeEnvironmentProviders } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
var NgxCurrencyInputMode;
(function (NgxCurrencyInputMode) {
NgxCurrencyInputMode[NgxCurrencyInputMode["Financial"] = 0] = "Financial";
NgxCurrencyInputMode[NgxCurrencyInputMode["Natural"] = 1] = "Natural";
})(NgxCurrencyInputMode || (NgxCurrencyInputMode = {}));
const NGX_CURRENCY_CONFIG = new InjectionToken('ngx-currency.config');
class InputManager {
constructor(_htmlInputElement) {
this._htmlInputElement = _htmlInputElement;
this._storedRawValue = null;
}
setCursorAt(position) {
this._htmlInputElement.focus();
this._htmlInputElement.setSelectionRange(position, position);
}
updateValueAndCursor(newRawValue, oldLength, selectionStart) {
this.rawValue = newRawValue;
const newLength = newRawValue.length;
selectionStart = selectionStart - (oldLength - newLength);
this.setCursorAt(selectionStart);
}
get canInputMoreNumbers() {
const onlyNumbers = this.rawValue?.replace(/[^0-9\u0660-\u0669\u06F0-\u06F9]/g, '') ?? '';
const hasReachedMaxLength = !(onlyNumbers.length >= this._htmlInputElement.maxLength &&
this._htmlInputElement.maxLength >= 0);
const selectionStart = this.inputSelection.selectionStart;
const selectionEnd = this.inputSelection.selectionEnd;
const haveNumberSelected = !!(selectionStart != selectionEnd &&
this._htmlInputElement.value
.substring(selectionStart, selectionEnd)
.match(/[^0-9\u0660-\u0669\u06F0-\u06F9]/));
const startWithZero = this._htmlInputElement.value.substring(0, 1) == '0';
return hasReachedMaxLength || haveNumberSelected || startWithZero;
}
get inputSelection() {
return {
selectionStart: this._htmlInputElement.selectionStart ?? 0,
selectionEnd: this._htmlInputElement.selectionEnd ?? 0,
};
}
get rawValue() {
return this._htmlInputElement && this._htmlInputElement.value;
}
set rawValue(value) {
this._storedRawValue = value;
if (this._htmlInputElement) {
this._htmlInputElement.value = value ?? '';
}
}
get storedRawValue() {
return this._storedRawValue || '';
}
}
class InputService {
constructor(htmlInputElement, _options) {
this._options = _options;
this._singleDigitRegex = new RegExp(/^[0-9\u0660-\u0669\u06F0-\u06F9]$/);
this._onlyNumbersRegex = new RegExp(/[^0-9\u0660-\u0669\u06F0-\u06F9]/g);
this._perArNumber = new Map([
['\u06F0', '0'],
['\u06F1', '1'],
['\u06F2', '2'],
['\u06F3', '3'],
['\u06F4', '4'],
['\u06F5', '5'],
['\u06F6', '6'],
['\u06F7', '7'],
['\u06F8', '8'],
['\u06F9', '9'],
['\u0660', '0'],
['\u0661', '1'],
['\u0662', '2'],
['\u0663', '3'],
['\u0664', '4'],
['\u0665', '5'],
['\u0666', '6'],
['\u0667', '7'],
['\u0668', '8'],
['\u0669', '9'],
]);
this.inputManager = new InputManager(htmlInputElement);
}
addNumber(keyCode) {
const { decimal, precision, inputMode } = this._options;
const keyChar = String.fromCharCode(keyCode);
const isDecimalChar = keyChar === this._options.decimal;
if (!this.rawValue) {
this.rawValue = this.applyMask(false, keyChar);
let selectionStart = undefined;
if (inputMode === NgxCurrencyInputMode.Natural && precision > 0) {
selectionStart = this.rawValue.indexOf(decimal);
if (isDecimalChar) {
selectionStart++;
}
}
this.updateFieldValue(selectionStart);
}
else {
const selectionStart = this.inputSelection.selectionStart;
const selectionEnd = this.inputSelection.selectionEnd;
const rawValueStart = this.rawValue.substring(0, selectionStart);
let rawValueEnd = this.rawValue.substring(selectionEnd, this.rawValue.length);
// In natural mode, replace decimals instead of shifting them.
const inDecimalPortion = rawValueStart.indexOf(decimal) !== -1;
if (inputMode === NgxCurrencyInputMode.Natural &&
inDecimalPortion &&
selectionStart === selectionEnd) {
rawValueEnd = rawValueEnd.substring(1);
}
const newValue = rawValueStart + keyChar + rawValueEnd;
let nextSelectionStart = selectionStart + 1;
const isDecimalOrThousands = isDecimalChar || keyChar === this._options.thousands;
if (isDecimalOrThousands && keyChar === rawValueEnd[0]) {
// If the cursor is just before the decimal or thousands separator and the user types the
// decimal or thousands separator, move the cursor past it.
nextSelectionStart++;
}
else if (!this._singleDigitRegex.test(keyChar)) {
// Ignore other non-numbers.
return;
}
this.rawValue = newValue;
this.updateFieldValue(nextSelectionStart);
}
}
applyMask(isNumber, rawValue, disablePadAndTrim = false) {
const { allowNegative, decimal, precision, prefix, suffix, thousands, min, inputMode, } = this._options;
let { max } = this._options;
rawValue = isNumber ? new Number(rawValue).toFixed(precision) : rawValue;
let onlyNumbers = rawValue.replace(this._onlyNumbersRegex, '');
if (!onlyNumbers && rawValue !== decimal) {
return '';
}
if (inputMode === NgxCurrencyInputMode.Natural &&
!isNumber &&
!disablePadAndTrim) {
rawValue = this.padOrTrimPrecision(rawValue);
onlyNumbers = rawValue.replace(this._onlyNumbersRegex, '');
}
let integerPart = onlyNumbers
.slice(0, onlyNumbers.length - precision)
.replace(/^\u0660*/g, '')
.replace(/^\u06F0*/g, '')
.replace(/^0*/g, '');
if (integerPart == '') {
integerPart = '0';
}
const integerValue = parseInt(integerPart);
integerPart = integerPart.replace(/\B(?=([0-9\u0660-\u0669\u06F0-\u06F9]{3})+(?![0-9\u0660-\u0669\u06F0-\u06F9]))/g, thousands);
if (thousands && integerPart.startsWith(thousands)) {
integerPart = integerPart.substring(1);
}
let newRawValue = integerPart;
const decimalPart = onlyNumbers.slice(onlyNumbers.length - precision);
const decimalValue = parseInt(decimalPart) || 0;
const isNegative = rawValue.indexOf('-') > -1;
// Ensure max is at least as large as min.
max =
max === null || max === undefined || min === null || min === undefined
? max
: Math.max(max, min);
// Ensure precision number works well with more than 2 digits
// 23 / 100... 233 / 1000 and so on
const divideBy = Number('1'.padEnd(precision + 1, '0'));
// Restrict to the min and max values.
let newValue = integerValue + decimalValue / divideBy;
newValue = isNegative ? -newValue : newValue;
if (max !== null && max !== undefined && newValue > max) {
return this.applyMask(true, max + '');
}
else if (min !== null && min !== undefined && newValue < min) {
return this.applyMask(true, min + '');
}
if (precision > 0) {
if (newRawValue == '0' && decimalPart.length < precision) {
newRawValue += decimal + '0'.repeat(precision - 1) + decimalPart;
}
else {
newRawValue += decimal + decimalPart;
}
}
// let isZero = newValue == 0;
const operator = isNegative && allowNegative /*&& !isZero */ ? '-' : '';
return operator + prefix + newRawValue + suffix;
}
padOrTrimPrecision(rawValue) {
const { decimal, precision } = this._options;
let decimalIndex = rawValue.lastIndexOf(decimal);
if (decimalIndex === -1) {
decimalIndex = rawValue.length;
rawValue += decimal;
}
let decimalPortion = rawValue
.substring(decimalIndex)
.replace(this._onlyNumbersRegex, '');
const actualPrecision = decimalPortion.length;
if (actualPrecision < precision) {
for (let i = actualPrecision; i < precision; i++) {
decimalPortion += '0';
}
}
else if (actualPrecision > precision) {
decimalPortion = decimalPortion.substring(0, decimalPortion.length + precision - actualPrecision);
}
return rawValue.substring(0, decimalIndex) + decimal + decimalPortion;
}
clearMask(rawValue) {
if (this.isNullable() && rawValue === '')
return null;
let value = (rawValue || '0')
.replace(this._options.prefix, '')
.replace(this._options.suffix, '');
if (this._options.thousands) {
value = value.replace(new RegExp('\\' + this._options.thousands, 'g'), '');
}
if (this._options.decimal) {
value = value.replace(this._options.decimal, '.');
}
this._perArNumber.forEach((val, key) => {
const re = new RegExp(key, 'g');
value = value.replace(re, val);
});
return parseFloat(value);
}
changeToNegative() {
if (this._options.allowNegative /*&& this.rawValue != ""*/ &&
this.rawValue?.charAt(0) != '-' /*&& this.value != 0*/) {
// Apply the mask to ensure the min and max values are enforced.
this.rawValue = this.applyMask(false, '-' + (this.rawValue ? this.rawValue : '0'));
}
}
changeToPositive() {
// Apply the mask to ensure the min and max values are enforced.
this.rawValue = this.applyMask(false, this.rawValue?.replace('-', '') ?? '');
}
removeNumber(keyCode) {
const { decimal, thousands, prefix, suffix, inputMode } = this._options;
if (this.isNullable() && this.value == 0) {
this.rawValue = null;
return;
}
let selectionEnd = this.inputSelection.selectionEnd;
let selectionStart = this.inputSelection.selectionStart;
const suffixStart = (this.rawValue?.length ?? 0) - suffix.length;
selectionEnd = Math.min(suffixStart, Math.max(selectionEnd, prefix.length));
selectionStart = Math.min(suffixStart, Math.max(selectionStart, prefix.length));
// Check if selection was entirely in the prefix or suffix.
if (selectionStart === selectionEnd &&
this.inputSelection.selectionStart !== this.inputSelection.selectionEnd) {
this.updateFieldValue(selectionStart);
return;
}
let decimalIndex = this.rawValue?.indexOf(decimal) ?? -1;
if (decimalIndex === -1) {
decimalIndex = this.rawValue?.length ?? 0;
}
let shiftSelection = 0;
let insertChars = '';
const isCursorInDecimals = decimalIndex < selectionEnd;
const isCursorImmediatelyAfterDecimalPoint = decimalIndex + 1 === selectionEnd;
if (selectionEnd === selectionStart) {
if (keyCode == 8) {
if (selectionStart <= prefix.length) {
return;
}
selectionStart--;
// If previous char isn't a number, go back one more.
if (!this.rawValue
?.substring(selectionStart, selectionStart + 1)
.match(/\d/)) {
selectionStart--;
}
// In natural mode, jump backwards when in decimal portion of number.
if (inputMode === NgxCurrencyInputMode.Natural && isCursorInDecimals) {
shiftSelection = -1;
// when removing a single whole number, replace it with 0
if (isCursorImmediatelyAfterDecimalPoint &&
(this.value ?? 0) < 10 &&
(this.value ?? 0) > -10) {
insertChars += '0';
}
}
}
else if (keyCode == 46 || keyCode == 63272) {
if (selectionStart === suffixStart) {
return;
}
selectionEnd++;
// If next char isn't a number, go one more.
if (!this.rawValue
?.substring(selectionStart, selectionStart + 1)
.match(/\d/)) {
selectionStart++;
selectionEnd++;
}
}
}
// In natural mode, replace decimals with 0s.
if (inputMode === NgxCurrencyInputMode.Natural &&
selectionStart > decimalIndex) {
const replacedDecimalCount = selectionEnd - selectionStart;
for (let i = 0; i < replacedDecimalCount; i++) {
insertChars += '0';
}
}
let selectionFromEnd = (this.rawValue?.length ?? 0) - selectionEnd;
this.rawValue =
this.rawValue?.substring(0, selectionStart) +
insertChars +
this.rawValue?.substring(selectionEnd);
// Remove leading thousand separator from raw value.
const startChar = this.rawValue.substring(prefix.length, prefix.length + 1);
if (startChar === thousands) {
this.rawValue =
this.rawValue.substring(0, prefix.length) +
this.rawValue.substring(prefix.length + 1);
selectionFromEnd = Math.min(selectionFromEnd, this.rawValue.length - prefix.length);
}
this.updateFieldValue(this.rawValue.length - selectionFromEnd + shiftSelection, true);
}
updateFieldValue(selectionStart, disablePadAndTrim = false) {
const newRawValue = this.applyMask(false, this.rawValue ?? '', disablePadAndTrim);
selectionStart ??= this.rawValue?.length ?? 0;
selectionStart = Math.max(this._options.prefix.length, Math.min(selectionStart, (this.rawValue?.length ?? 0) - this._options.suffix.length));
this.inputManager.updateValueAndCursor(newRawValue, this.rawValue?.length ?? 0, selectionStart);
}
updateOptions(options) {
const value = this.value;
this._options = options;
this.value = value;
}
prefixLength() {
return this._options.prefix.length;
}
suffixLength() {
return this._options.suffix.length;
}
isNullable() {
return this._options.nullable;
}
get canInputMoreNumbers() {
return this.inputManager.canInputMoreNumbers;
}
get inputSelection() {
return this.inputManager.inputSelection;
}
get rawValue() {
return this.inputManager.rawValue;
}
set rawValue(value) {
this.inputManager.rawValue = value;
}
get storedRawValue() {
return this.inputManager.storedRawValue;
}
get value() {
return this.clearMask(this.rawValue);
}
set value(value) {
this.rawValue = this.applyMask(true, '' + value);
}
_isNullOrUndefined(value) {
return value === null || value === undefined;
}
}
class InputHandler {
constructor(htmlInputElement, options) {
this.onModelChange = () => undefined;
this.onModelTouched = () => undefined;
this.inputService = new InputService(htmlInputElement, options);
}
handleCut() {
setTimeout(() => {
this.inputService.updateFieldValue();
this.setValue(this.inputService.value);
this.onModelChange(this.inputService.value);
}, 0);
}
handleInput() {
const rawValue = this.inputService.rawValue ?? '';
const selectionStart = this.inputService.inputSelection.selectionStart;
const keyCode = rawValue.charCodeAt(selectionStart - 1);
const rawValueLength = rawValue.length;
const storedRawValueLength = this.inputService.storedRawValue.length;
if (Math.abs(rawValueLength - storedRawValueLength) != 1) {
this.inputService.updateFieldValue(selectionStart);
this.onModelChange(this.inputService.value);
return;
}
// Restore the old value.
this.inputService.rawValue = this.inputService.storedRawValue;
if (rawValueLength < storedRawValueLength) {
// Chrome Android seems to move the cursor in response to a backspace AFTER processing the
// input event, so we need to wrap this in a timeout.
this.timer(() => {
// Move the cursor to just after the deleted value.
this.inputService.updateFieldValue(selectionStart + 1);
// Then backspace it.
this.inputService.removeNumber(8);
this.onModelChange(this.inputService.value);
}, 0);
}
if (rawValueLength > storedRawValueLength) {
// Move the cursor to just before the new value.
this.inputService.updateFieldValue(selectionStart - 1);
// Process the character like a keypress.
this._handleKeypressImpl(keyCode);
}
}
handleKeydown(event) {
const keyCode = event.which || event.charCode || event.keyCode;
if (keyCode == 8 || keyCode == 46 || keyCode == 63272) {
event.preventDefault();
if (this.inputService.inputSelection.selectionStart <=
this.inputService.prefixLength() &&
this.inputService.inputSelection.selectionEnd >=
(this.inputService.rawValue?.length ?? 0) -
this.inputService.suffixLength()) {
this.clearValue();
}
else {
this.inputService.removeNumber(keyCode);
this.onModelChange(this.inputService.value);
}
}
}
clearValue() {
this.setValue(this.inputService.isNullable() ? null : 0);
this.onModelChange(this.inputService.value);
}
handleKeypress(event) {
const keyCode = event.which || event.charCode || event.keyCode;
event.preventDefault();
if (keyCode === 97 && event.ctrlKey) {
return;
}
this._handleKeypressImpl(keyCode);
}
_handleKeypressImpl(keyCode) {
switch (keyCode) {
case undefined:
case 9:
case 13:
return;
case 43:
this.inputService.changeToPositive();
break;
case 45:
this.inputService.changeToNegative();
break;
default:
if (this.inputService.canInputMoreNumbers) {
const selectionRangeLength = Math.abs(this.inputService.inputSelection.selectionEnd -
this.inputService.inputSelection.selectionStart);
if (selectionRangeLength == (this.inputService.rawValue?.length ?? 0)) {
this.setValue(null);
}
this.inputService.addNumber(keyCode);
}
break;
}
this.onModelChange(this.inputService.value);
}
handlePaste() {
setTimeout(() => {
this.inputService.updateFieldValue();
this.setValue(this.inputService.value);
this.onModelChange(this.inputService.value);
}, 1);
}
updateOptions(options) {
this.inputService.updateOptions(options);
}
getOnModelChange() {
return this.onModelChange;
}
setOnModelChange(callbackFunction) {
this.onModelChange = callbackFunction;
}
getOnModelTouched() {
return this.onModelTouched;
}
setOnModelTouched(callbackFunction) {
this.onModelTouched = callbackFunction;
}
setValue(value) {
this.inputService.value = value;
}
/**
* Passthrough to setTimeout that can be stubbed out in tests.
*/
timer(callback, delayMilliseconds) {
setTimeout(callback, delayMilliseconds);
}
}
class NgxCurrencyDirective {
set currencyMask(value) {
if (typeof value === 'string')
return;
this._options = value;
}
/**
* @deprecated Use currencyMask input instead
*/
set options(value) {
this._options = value;
}
constructor(globalOptions, keyValueDiffers, _elementRef) {
this._elementRef = _elementRef;
this._options = {};
this._optionsTemplate = {
align: 'right',
allowNegative: true,
allowZero: true,
decimal: '.',
precision: 2,
prefix: '$ ',
suffix: '',
thousands: ',',
nullable: false,
inputMode: NgxCurrencyInputMode.Financial,
...globalOptions,
};
this._keyValueDiffer = keyValueDiffers.find({}).create();
this._inputHandler = new InputHandler(this._elementRef.nativeElement, {
...this._optionsTemplate,
...this._options,
});
}
ngAfterViewInit() {
this._elementRef.nativeElement.style.textAlign =
this._options?.align ?? this._optionsTemplate.align;
}
ngDoCheck() {
if (this._keyValueDiffer.diff(this._options)) {
this._elementRef.nativeElement.style.textAlign =
this._options?.align ?? this._optionsTemplate.align;
this._inputHandler.updateOptions({
...this._optionsTemplate,
...this._options,
});
}
}
handleBlur(event) {
this._inputHandler.getOnModelTouched().apply(event);
}
handleCut() {
if (!this.isChromeAndroid()) {
if (!this.isReadOnly())
this._inputHandler.handleCut();
}
}
handleInput() {
if (this.isChromeAndroid()) {
if (!this.isReadOnly())
this._inputHandler.handleInput();
}
}
handleKeydown(event) {
if (!this.isChromeAndroid()) {
if (!this.isReadOnly())
this._inputHandler.handleKeydown(event);
}
}
handleKeypress(event) {
if (!this.isChromeAndroid()) {
if (!this.isReadOnly())
this._inputHandler.handleKeypress(event);
}
}
handlePaste() {
if (!this.isChromeAndroid()) {
if (!this.isReadOnly())
this._inputHandler.handlePaste();
}
}
handleDrop(event) {
if (!this.isChromeAndroid()) {
event.preventDefault();
}
}
isChromeAndroid() {
return (/chrome/i.test(navigator.userAgent) &&
/android/i.test(navigator.userAgent));
}
isReadOnly() {
return this._elementRef.nativeElement.hasAttribute('readonly');
}
registerOnChange(callbackFunction) {
this._inputHandler.setOnModelChange(callbackFunction);
}
registerOnTouched(callbackFunction) {
this._inputHandler.setOnModelTouched(callbackFunction);
}
setDisabledState(isDisabled) {
this._elementRef.nativeElement.disabled = isDisabled;
}
writeValue(value) {
this._inputHandler.setValue(value);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: NgxCurrencyDirective, deps: [{ token: NGX_CURRENCY_CONFIG, optional: true }, { token: i0.KeyValueDiffers }, { token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.0", type: NgxCurrencyDirective, isStandalone: true, selector: "input[currencyMask]", inputs: { currencyMask: "currencyMask", options: "options" }, host: { listeners: { "blur": "handleBlur($event)", "cut": "handleCut()", "input": "handleInput()", "keydown": "handleKeydown($event)", "keypress": "handleKeypress($event)", "paste": "handlePaste()", "drop": "handleDrop($event)" } }, providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NgxCurrencyDirective),
multi: true,
},
], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: NgxCurrencyDirective, decorators: [{
type: Directive,
args: [{
selector: 'input[currencyMask]',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NgxCurrencyDirective),
multi: true,
},
],
}]
}], ctorParameters: () => [{ type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [NGX_CURRENCY_CONFIG]
}] }, { type: i0.KeyValueDiffers }, { type: i0.ElementRef }], propDecorators: { currencyMask: [{
type: Input
}], options: [{
type: Input
}], handleBlur: [{
type: HostListener,
args: ['blur', ['$event']]
}], handleCut: [{
type: HostListener,
args: ['cut']
}], handleInput: [{
type: HostListener,
args: ['input']
}], handleKeydown: [{
type: HostListener,
args: ['keydown', ['$event']]
}], handleKeypress: [{
type: HostListener,
args: ['keypress', ['$event']]
}], handlePaste: [{
type: HostListener,
args: ['paste']
}], handleDrop: [{
type: HostListener,
args: ['drop', ['$event']]
}] } });
function provideEnvironmentNgxCurrency(config) {
return makeEnvironmentProviders([
{
provide: NGX_CURRENCY_CONFIG,
useValue: config,
},
]);
}
/*
* Public API Surface of ngx-currency
*/
/**
* Generated bundle index. Do not edit.
*/
export { NGX_CURRENCY_CONFIG, NgxCurrencyDirective, NgxCurrencyInputMode, provideEnvironmentNgxCurrency };
//# sourceMappingURL=ngx-currency.mjs.map