ngx-mask
Version:
Input masking for modern Angular — Reactive, template-driven and Signal Forms, zoneless and SSR ready. Dates, numbers, separators, custom patterns.
1,045 lines (1,036 loc) • 214 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, EventEmitter, inject, Injectable, signal, ElementRef, Renderer2, makeEnvironmentProviders, input, model, booleanAttribute, output, ChangeDetectorRef, Injector, effect, untracked, HostListener, Directive, Pipe } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { NgControl, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
var MaskExpression;
(function (MaskExpression) {
MaskExpression["SEPARATOR"] = "separator";
MaskExpression["PERCENT"] = "percent";
MaskExpression["IP"] = "IP";
MaskExpression["CPF_CNPJ"] = "CPF_CNPJ";
MaskExpression["CPF_CNPJ_ALPHA"] = "CPF_CNPJ_ALPHA";
MaskExpression["MONTH"] = "M";
MaskExpression["MONTHS"] = "M0";
MaskExpression["MINUTE"] = "m";
MaskExpression["HOUR"] = "h";
MaskExpression["HOURS"] = "H";
MaskExpression["MINUTES"] = "m0";
MaskExpression["HOURS_HOUR"] = "Hh";
MaskExpression["SECONDS"] = "s0";
MaskExpression["HOURS_MINUTES_SECONDS"] = "Hh:m0:s0";
MaskExpression["EMAIL_MASK"] = "A*@A*.A*";
MaskExpression["HOURS_MINUTES"] = "Hh:m0";
MaskExpression["MINUTES_SECONDS"] = "m0:s0";
MaskExpression["DAYS_MONTHS_YEARS"] = "d0/M0/0000";
MaskExpression["DAYS_MONTHS"] = "d0/M0";
MaskExpression["DAYS"] = "d0";
MaskExpression["DAY"] = "d";
MaskExpression["SECOND"] = "s";
MaskExpression["LETTER_S"] = "S";
MaskExpression["DOT"] = ".";
MaskExpression["COMMA"] = ",";
MaskExpression["CURLY_BRACKETS_LEFT"] = "{";
MaskExpression["CURLY_BRACKETS_RIGHT"] = "}";
MaskExpression["MINUS"] = "-";
MaskExpression["OR"] = "||";
MaskExpression["HASH"] = "#";
MaskExpression["EMPTY_STRING"] = "";
MaskExpression["SYMBOL_STAR"] = "*";
MaskExpression["SYMBOL_QUESTION"] = "?";
MaskExpression["SLASH"] = "/";
MaskExpression["WHITE_SPACE"] = " ";
MaskExpression["NUMBER_ZERO"] = "0";
MaskExpression["NUMBER_NINE"] = "9";
MaskExpression["BACKSPACE"] = "Backspace";
MaskExpression["DELETE"] = "Delete";
MaskExpression["ARROW_LEFT"] = "ArrowLeft";
MaskExpression["ARROW_UP"] = "ArrowUp";
MaskExpression["DOUBLE_ZERO"] = "00";
})(MaskExpression || (MaskExpression = {}));
const NGX_MASK_CONFIG = new InjectionToken('ngx-mask config');
const NEW_CONFIG = new InjectionToken('new ngx-mask config');
const INITIAL_CONFIG = new InjectionToken('initial ngx-mask config');
const initialConfig = {
suffix: '',
prefix: '',
thousandSeparator: ' ',
decimalMarker: ['.', ','],
clearIfNotMatch: false,
showMaskTyped: false,
instantPrefix: false,
placeHolderCharacter: '_',
dropSpecialCharacters: true,
hiddenInput: false,
shownMaskExpression: '',
separatorLimit: '',
allowNegativeNumbers: false,
validation: true,
specialCharacters: ['-', '/', '(', ')', '.', ':', ' ', '+', ',', '@', '[', ']', '"', "'"],
leadZeroDateTime: false,
apm: false,
leadZero: false,
typeFromDecimals: false,
keepCharacterPositions: false,
triggerOnMaskChange: false,
inputTransformFn: (value) => value,
outputTransformFn: (value) => value,
maskFilled: new EventEmitter(),
maskAliases: {},
defaultValueOnBlur: null,
patterns: {
'0': {
pattern: new RegExp('\\d'),
},
'9': {
pattern: new RegExp('\\d'),
optional: true,
},
X: {
pattern: new RegExp('\\d'),
symbol: '*',
},
A: {
pattern: new RegExp('[a-zA-Z0-9]'),
},
S: {
pattern: new RegExp('[a-zA-Z]'),
},
U: {
pattern: new RegExp('[A-Z]'),
},
L: {
pattern: new RegExp('[a-z]'),
},
d: {
pattern: new RegExp('\\d'),
symbol: '*',
},
m: {
pattern: new RegExp('\\d'),
},
M: {
pattern: new RegExp('\\d'),
symbol: '*',
},
H: {
pattern: new RegExp('\\d'),
},
h: {
pattern: new RegExp('\\d'),
},
s: {
pattern: new RegExp('\\d'),
},
},
};
/**
* Built-in mask tokens dispatched by exact equality (IP, CPF_CNPJ, CPF_CNPJ_ALPHA, email)
* or by a reserved prefix (separator, percent) inside the mask pipeline. User-defined
* aliases must not shadow them — substituting e.g. 'IP' early would break the built-in
* exact-equality dispatch deep in the applier.
*/
const RESERVED_MASK_TOKENS = new Set([
MaskExpression.IP,
MaskExpression.CPF_CNPJ,
MaskExpression.CPF_CNPJ_ALPHA,
MaskExpression.EMAIL_MASK,
MaskExpression.SEPARATOR,
MaskExpression.PERCENT,
]);
/** Tracks alias keys already warned about, so the shadowing warning fires once per key. */
const warnedShadowedAliases = new Set();
/**
* Resolves a user-defined mask alias to its mask expression. Returns the input expression
* unchanged when no alias matches or when the alias key shadows a built-in token (in which
* case a console warning is emitted once per key and the built-in wins).
*/
function resolveMaskAlias(maskExpression, maskAliases) {
const expression = maskExpression ?? MaskExpression.EMPTY_STRING;
if (!expression || !maskAliases) {
return expression;
}
const aliased = maskAliases[expression];
if (typeof aliased !== 'string') {
return expression;
}
if (RESERVED_MASK_TOKENS.has(expression)) {
if (!warnedShadowedAliases.has(expression)) {
warnedShadowedAliases.add(expression);
// eslint-disable-next-line no-console
console.warn(`ngx-mask: mask alias "${expression}" shadows a built-in mask token and is ignored. Rename the alias.`);
}
return expression;
}
return aliased;
}
const timeMasks = [
MaskExpression.HOURS_MINUTES_SECONDS,
MaskExpression.HOURS_MINUTES,
MaskExpression.MINUTES_SECONDS,
];
const withoutValidation = [
MaskExpression.PERCENT,
MaskExpression.HOURS_HOUR,
MaskExpression.SECONDS,
MaskExpression.MINUTES,
MaskExpression.SEPARATOR,
MaskExpression.DAYS_MONTHS_YEARS,
MaskExpression.DAYS_MONTHS,
MaskExpression.DAYS,
MaskExpression.MONTHS,
];
/** CPF_CNPJ / CPF_CNPJ_ALPHA pre-processor (moved verbatim from applyMask). Sets
* `this.cpfCnpjError`, rewrites the mask, then falls through to the generic loop.
* Discriminated by exact equality BEFORE any startsWith handler — the 'H' of HOURS
* would otherwise catch CPF_CNPJ_ALPHA by substring (CODEBASE_NOTES #3/#4). */
const cpfCnpjHandler = function (state, params) {
const isCpfCnpjAlpha = state.maskExpression === MaskExpression.CPF_CNPJ_ALPHA;
this.cpfCnpjError = params.arr.length !== 11 && params.arr.length !== 14;
const valueHasAnyLetter = /[a-zA-Z]/.test(state.processedValue);
if (valueHasAnyLetter && isCpfCnpjAlpha) {
state.maskExpression = 'AA.AAA.AAA/AAAA-00';
}
else if (params.arr.length > 11) {
state.maskExpression = isCpfCnpjAlpha ? 'AA.AAA.AAA/AAAA-00' : '00.000.000/0000-00';
}
else {
state.maskExpression = '000.000.000-00';
}
return state;
};
/**
* Generic character-by-character pattern loop (moved verbatim from applyMask,
* original lines 581-968). This is the default/fallback handler — always runs when
* no exact/startsWith handler matched. IP and CPF_CNPJ pre-processors rewrite
* `state.maskExpression` and then fall through into this loop.
*
* The inline HOURS/HOUR/MINUTE/SECOND/DAY/MONTH sub-branches (including the day-window
* anchor flow-gating of #1611/#1513, CODEBASE_NOTES.md #20) are preserved unmodified —
* they are explicitly out of scope for the phase-1 extraction boundary.
*
* Note: several branches read `this.maskExpression` (the instance field) rather than
* the local `maskExpression` — this distinction is preserved exactly as in the
* original source.
*/
const genericPatternHandler = function (state, params) {
const { inputArray } = params;
const maskExpression = state.maskExpression;
const processedValue = state.processedValue;
let { cursor, result, multi, processedPosition, stepBack } = state;
for (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let i = 0, inputSymbol = inputArray[0]; i < inputArray.length; i++, inputSymbol = inputArray[i] ?? MaskExpression.EMPTY_STRING) {
if (cursor === maskExpression.length) {
break;
}
const symbolStarInPattern = MaskExpression.SYMBOL_STAR in this.patterns;
if (this._checkSymbolMask(inputSymbol, maskExpression[cursor] ?? MaskExpression.EMPTY_STRING) &&
maskExpression[cursor + 1] === MaskExpression.SYMBOL_QUESTION) {
result += inputSymbol;
cursor += 2;
}
else if (maskExpression[cursor + 1] === MaskExpression.SYMBOL_STAR &&
multi &&
this._checkSymbolMask(inputSymbol, maskExpression[cursor + 2] ?? MaskExpression.EMPTY_STRING)) {
result += inputSymbol;
cursor += 3;
multi = false;
}
else if (this._checkSymbolMask(inputSymbol, maskExpression[cursor] ?? MaskExpression.EMPTY_STRING) &&
maskExpression[cursor + 1] === MaskExpression.SYMBOL_STAR &&
!symbolStarInPattern) {
result += inputSymbol;
multi = true;
}
else if (maskExpression[cursor + 1] === MaskExpression.SYMBOL_QUESTION &&
this._checkSymbolMask(inputSymbol, maskExpression[cursor + 2] ?? MaskExpression.EMPTY_STRING)) {
result += inputSymbol;
cursor += 3;
}
else if (this._checkSymbolMask(inputSymbol, maskExpression[cursor] ?? MaskExpression.EMPTY_STRING)) {
if (maskExpression[cursor] === MaskExpression.HOURS) {
if (this.apm ? Number(inputSymbol) > 9 : Number(inputSymbol) > 2) {
processedPosition = !this.leadZeroDateTime
? processedPosition + 1
: processedPosition;
cursor += 1;
this._shiftStep(cursor);
i--;
if (this.leadZeroDateTime) {
result += '0';
}
continue;
}
}
if (maskExpression[cursor] === MaskExpression.HOUR) {
if (this.apm
? (result.length === 1 && Number(result) > 1) ||
(result === '1' && Number(inputSymbol) > 2) ||
(processedValue.slice(cursor - 1, cursor).length === 1 &&
Number(processedValue.slice(cursor - 1, cursor)) > 2) ||
(processedValue.slice(cursor - 1, cursor) === '1' &&
Number(inputSymbol) > 2)
: (result === '2' && Number(inputSymbol) > 3) ||
((result.slice(cursor - 2, cursor) === '2' ||
result.slice(cursor - 3, cursor) === '2' ||
result.slice(cursor - 4, cursor) === '2' ||
result.slice(cursor - 1, cursor) === '2') &&
Number(inputSymbol) > 3 &&
cursor > 10)) {
processedPosition = processedPosition + 1;
cursor += 1;
i--;
continue;
}
}
if (maskExpression[cursor] === MaskExpression.MINUTE ||
maskExpression[cursor] === MaskExpression.SECOND) {
if (Number(inputSymbol) > 5) {
processedPosition = !this.leadZeroDateTime
? processedPosition + 1
: processedPosition;
cursor += 1;
this._shiftStep(cursor);
i--;
if (this.leadZeroDateTime) {
result += '0';
}
continue;
}
}
const daysCount = 31;
const inputValueCursor = processedValue[cursor];
const inputValueCursorPlusOne = processedValue[cursor + 1];
const inputValueCursorPlusTwo = processedValue[cursor + 2];
const inputValueCursorMinusOne = processedValue[cursor - 1];
const inputValueCursorMinusTwo = processedValue[cursor - 2];
const inputValueSliceMinusThreeMinusOne = processedValue.slice(cursor - 3, cursor - 1);
const inputValueSliceMinusOnePlusOne = processedValue.slice(cursor - 1, cursor + 1);
const inputValueSliceCursorPlusTwo = processedValue.slice(cursor, cursor + 2);
const inputValueSliceMinusTwoCursor = processedValue.slice(cursor - 2, cursor);
// Issue #1523: when the date token directly abuts a plain digit
// token (year-first masks without separators like 00M0d0 or
// 0000M0d0), the backward-looking windows below read the previous
// field's digits (year) as day/month digits and skip valid input.
// Disable only those backward heuristics in that layout.
const tokenAbutsDigitField = maskExpression[cursor - 1] === MaskExpression.NUMBER_ZERO;
if (maskExpression[cursor] === MaskExpression.DAY) {
const maskStartWithMonth = maskExpression.slice(0, 2) === MaskExpression.MONTHS;
const startWithMonthInput = maskExpression.slice(0, 2) === MaskExpression.MONTHS &&
this.specialCharacters.includes(inputValueCursorMinusTwo);
// Issue #1611: `cursor` indexes the mask, `i` indexes the input.
// On paste/writeValue of a bare digit string the cursor runs
// ahead of the input by every emitted separator, so cursor-based
// slices read year digits instead of the day — anchor the day
// window on the input index in those flows. Keystroke flows keep
// the historical cursor anchor (with showMaskTyped the value also
// carries placeholder chars, where the input anchor misreads).
const dayWindowStart = params.justPasted || this.writingValue ? i : cursor;
const dayWindowSlice = processedValue.slice(dayWindowStart, dayWindowStart + 2);
const dayWindowNext = processedValue[dayWindowStart + 1];
if ((Number(inputSymbol) > 3 && this.leadZeroDateTime) ||
(!maskStartWithMonth &&
(Number(inputValueSliceCursorPlusTwo) > daysCount ||
(!tokenAbutsDigitField &&
Number(inputValueSliceMinusOnePlusOne) > daysCount) ||
this.specialCharacters.includes(inputValueCursorPlusOne))) ||
(startWithMonthInput
? Number(inputValueSliceMinusOnePlusOne) > daysCount ||
(!this.specialCharacters.includes(inputValueCursor) &&
this.specialCharacters.includes(inputValueCursorPlusTwo)) ||
this.specialCharacters.includes(inputValueCursor)
: Number(dayWindowSlice) > daysCount ||
(this.specialCharacters.includes(dayWindowNext) && !params.backspaced))) {
processedPosition = !this.leadZeroDateTime
? processedPosition + 1
: processedPosition;
cursor += 1;
this._shiftStep(cursor);
i--;
if (this.leadZeroDateTime) {
result += '0';
}
continue;
}
}
if (maskExpression[cursor] === MaskExpression.MONTH) {
const monthsCount = 12;
// Issue #1513: the backward-looking day/month windows below
// assume the digits before the MONTH token belong to a DAY
// field. In year-first masks with separators (e.g. 0000-M0-d0)
// they read YEAR digits through the separator and shove a
// spurious leading zero into the month. tokenAbutsDigitField
// (#1523) only covers separator-less layouts, so derive field
// ownership from the mask itself: locate the field (maximal
// run of non-special tokens) immediately preceding this MONTH
// token — a plain digit run of 3+ characters is a year, not a
// day, and the day-based heuristics must not fire.
let precedingFieldEnd = cursor - 1;
while (precedingFieldEnd >= 0 &&
this.specialCharacters.includes(maskExpression[precedingFieldEnd])) {
precedingFieldEnd--;
}
let precedingFieldStart = precedingFieldEnd;
while (precedingFieldStart >= 0 &&
!this.specialCharacters.includes(maskExpression[precedingFieldStart])) {
precedingFieldStart--;
}
const precedingField = maskExpression.slice(precedingFieldStart + 1, precedingFieldEnd + 1);
const yearFieldPrecedesMonth = precedingField.length > 2 &&
precedingField
.split(MaskExpression.EMPTY_STRING)
.every((token) => token === MaskExpression.NUMBER_ZERO);
// mask without day
const withoutDays = cursor === 0 &&
(Number(inputSymbol) > 2 ||
Number(inputValueSliceCursorPlusTwo) > monthsCount ||
(this.specialCharacters.includes(inputValueCursorPlusOne) &&
!params.backspaced));
// day<10 && month<12 for input
const specialChart = maskExpression.slice(cursor + 2, cursor + 3);
const day1monthInput = inputValueSliceMinusThreeMinusOne.includes(specialChart) &&
maskExpression.includes('d0') &&
((this.specialCharacters.includes(inputValueCursorMinusTwo) &&
Number(inputValueSliceMinusOnePlusOne) > monthsCount &&
!this.specialCharacters.includes(inputValueCursor)) ||
this.specialCharacters.includes(inputValueCursor));
// month<12 && day<10 for input
const day2monthInput = !tokenAbutsDigitField &&
!yearFieldPrecedesMonth &&
Number(inputValueSliceMinusThreeMinusOne) <= daysCount &&
!this.specialCharacters.includes(inputValueSliceMinusThreeMinusOne) &&
this.specialCharacters.includes(inputValueCursorMinusOne) &&
(Number(inputValueSliceCursorPlusTwo) > monthsCount ||
this.specialCharacters.includes(inputValueCursorPlusOne));
// cursor === 5 && without days
const day2monthInputDot = (Number(inputValueSliceCursorPlusTwo) > monthsCount && cursor === 5) ||
(this.specialCharacters.includes(inputValueCursorPlusOne) && cursor === 5);
// // day<10 && month<12 for paste whole data
const day1monthPaste = !tokenAbutsDigitField &&
!yearFieldPrecedesMonth &&
Number(inputValueSliceMinusThreeMinusOne) > daysCount &&
!this.specialCharacters.includes(inputValueSliceMinusThreeMinusOne) &&
!this.specialCharacters.includes(inputValueSliceMinusTwoCursor) &&
Number(inputValueSliceMinusTwoCursor) > monthsCount &&
maskExpression.includes('d0');
// 10<day<31 && month<12 for paste whole data
const day2monthPaste = !tokenAbutsDigitField &&
!yearFieldPrecedesMonth &&
Number(inputValueSliceMinusThreeMinusOne) <= daysCount &&
!this.specialCharacters.includes(inputValueSliceMinusThreeMinusOne) &&
!this.specialCharacters.includes(inputValueCursorMinusOne) &&
Number(inputValueSliceMinusOnePlusOne) > monthsCount;
if ((Number(inputSymbol) > 1 && this.leadZeroDateTime) ||
withoutDays ||
day1monthInput ||
day2monthPaste ||
day1monthPaste ||
day2monthInput ||
(day2monthInputDot && !this.leadZeroDateTime)) {
processedPosition = !this.leadZeroDateTime
? processedPosition + 1
: processedPosition;
cursor += 1;
this._shiftStep(cursor);
i--;
if (this.leadZeroDateTime) {
result += '0';
}
continue;
}
}
result += inputSymbol;
cursor++;
}
else if (this.specialCharacters.includes(inputSymbol) &&
maskExpression[cursor] === inputSymbol) {
result += inputSymbol;
cursor++;
}
else if (this.specialCharacters.indexOf(maskExpression[cursor] ?? MaskExpression.EMPTY_STRING) !== -1) {
result += maskExpression[cursor];
cursor++;
this._shiftStep(cursor);
i--;
}
else if (maskExpression[cursor] === MaskExpression.NUMBER_NINE && this.showMaskTyped) {
this._shiftStep(cursor);
}
else if (this.patterns[maskExpression[cursor] ?? MaskExpression.EMPTY_STRING] &&
this.patterns[maskExpression[cursor] ?? MaskExpression.EMPTY_STRING]?.optional) {
// If the input symbol is whitespace or doesn't match the pattern,
// skip it without consuming the mask position
if (inputSymbol.trim() === MaskExpression.EMPTY_STRING) {
// Skip whitespace input, don't advance mask cursor
continue;
}
if (!!inputArray[cursor] &&
maskExpression !== '099.099.099.099' &&
maskExpression !== '000.000.000-00' &&
maskExpression !== '00.000.000/0000-00' &&
!maskExpression.match(/^9+\.0+$/) &&
!this.patterns[maskExpression[cursor] ?? MaskExpression.EMPTY_STRING]?.optional) {
result += inputArray[cursor];
}
if (maskExpression.includes(MaskExpression.NUMBER_NINE + MaskExpression.SYMBOL_STAR) &&
maskExpression.includes(MaskExpression.NUMBER_ZERO + MaskExpression.SYMBOL_STAR)) {
cursor++;
}
cursor++;
i--;
}
else if (this.maskExpression[cursor + 1] === MaskExpression.SYMBOL_STAR &&
// A typed char that exactly matches the mask's literal char at cursor+2
// (e.g. the '@' in 'A*@A*.A*') always terminates the 'A*' run, regardless
// of whether that literal is registered in `specialCharacters` — an
// explicitly empty `specialCharacters` list must not make mask literals
// unmatchable (#1512).
(this._findSpecialChar(this.maskExpression[cursor + 2] ?? MaskExpression.EMPTY_STRING)
? this._findSpecialChar(inputSymbol) === this.maskExpression[cursor + 2]
: inputSymbol === this.maskExpression[cursor + 2]) &&
multi) {
cursor += 3;
result += inputSymbol;
}
else if (this.maskExpression[cursor + 1] === MaskExpression.SYMBOL_QUESTION &&
(this._findSpecialChar(this.maskExpression[cursor + 2] ?? MaskExpression.EMPTY_STRING)
? this._findSpecialChar(inputSymbol) === this.maskExpression[cursor + 2]
: inputSymbol === this.maskExpression[cursor + 2]) &&
multi) {
cursor += 3;
result += inputSymbol;
}
else if (this.showMaskTyped &&
this.specialCharacters.indexOf(inputSymbol) < 0 &&
inputSymbol !== this.placeHolderCharacter &&
this.placeHolderCharacter.length === 1) {
stepBack = true;
}
}
state.cursor = cursor;
state.result = result;
state.multi = multi;
state.processedPosition = processedPosition;
state.stepBack = stepBack;
return state;
};
/** IP pre-processor (moved verbatim from applyMask). Sets `this.ipError`, rewrites
* the mask to `099.099.099.099`, then falls through to the generic loop. */
const ipHandler = function (state) {
const valuesIP = state.processedValue.split(MaskExpression.DOT);
this.ipError = this._validIP(valuesIP);
state.maskExpression = '099.099.099.099';
return state;
};
/** PERCENT handler (moved verbatim from applyMask). startsWith(PERCENT); terminal —
* produces the final `result`, no fallthrough. */
const percentHandler = function (state, params) {
const { backspaced } = params;
const { cursor } = state;
let processedValue = state.processedValue;
if (processedValue.match('[a-z]|[A-Z]') ||
// eslint-disable-next-line no-useless-escape
(processedValue.match(/[-!$%^&*()_+|~=`{}\[\]:";'<>?,\/.]/) && !backspaced)) {
processedValue = this._stripToDecimal(processedValue);
const precision = this.getPrecision(state.maskExpression);
processedValue = this.checkInputPrecision(processedValue, precision, this.decimalMarker);
}
const decimalMarker = typeof this.decimalMarker === 'string' ? this.decimalMarker : MaskExpression.DOT;
if (processedValue.indexOf(decimalMarker) > 0 &&
!this.percentage(processedValue.substring(0, processedValue.indexOf(decimalMarker)))) {
let base = processedValue.substring(0, processedValue.indexOf(decimalMarker) - 1);
if (this.allowNegativeNumbers &&
processedValue.slice(cursor, cursor + 1) === MaskExpression.MINUS &&
!backspaced) {
base = processedValue.substring(0, processedValue.indexOf(decimalMarker));
}
processedValue = `${base}${processedValue.substring(processedValue.indexOf(decimalMarker), processedValue.length)}`;
}
let value;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.allowNegativeNumbers && processedValue.slice(cursor, cursor + 1) === MaskExpression.MINUS
? (value = `${MaskExpression.MINUS}${processedValue.slice(cursor + 1, cursor + processedValue.length)}`)
: (value = processedValue);
if (this.percentage(value)) {
state.result = this._splitPercentZero(processedValue);
}
else {
state.result = this._splitPercentZero(processedValue.substring(0, processedValue.length - 1));
}
state.processedValue = processedValue;
return state;
};
/**
* SEPARATOR handler (moved verbatim from applyMask, original lines 219-580).
* Discriminator: `maskExpression.startsWith(MaskExpression.SEPARATOR)`. Produces the
* final `result` for this call.
*
* Two sequential zero-handling stages are preserved together, in original relative
* order (CODEBASE_NOTES.md #13): the `if (backspaced)` guard block and the
* precision-0 leading-zero stripper.
*
* The `typeFromDecimals` sub-case (#733/#1414/#1315) early-returns the final string
* via `state.earlyReturn` after calling `cb()` — this must bypass the shared
* post-processing in `applyMask` exactly, or the caret-pin `cb()` contract breaks.
*/
const separatorHandler = function (state, params) {
const { backspaced, justPasted, startsWithPrefix, prefixAlreadyRemovedByCaller, cb, inputValue, } = params;
let { processedValue, processedPosition, backspaceShift, shift, stepBack } = state;
let result;
if (processedValue.match('[wа-яА-Я]') ||
processedValue.match('[ЁёА-я]') ||
processedValue.match('[a-z]|[A-Z]') ||
processedValue.match(/[-@#!$%\\^&*()_£¬'+|~=`{}\]:";<>.?/]/) ||
processedValue.match('[^A-Za-z0-9,]')) {
processedValue = this._stripToDecimal(processedValue);
}
const precision = this.getPrecision(state.maskExpression);
let decimalMarker = this.decimalMarker;
if (Array.isArray(this.decimalMarker)) {
if (this.actualValue.includes(this.decimalMarker[0]) ||
this.actualValue.includes(this.decimalMarker[1])) {
decimalMarker = this.actualValue.includes(this.decimalMarker[0])
? this.decimalMarker[0]
: this.decimalMarker[1];
}
else {
decimalMarker = this.decimalMarker.find((dm) => dm !== this.thousandSeparator);
}
}
// Issues #733/#1414/#1315: opt-in "banking" typing mode — typed digits fill
// the value from the decimal end, ATM/calculator style (5 -> 0.05 -> 0.57
// -> 5.73); backspace shifts digits back to the right. Only keystroke and
// backspace flows are reinterpreted; paste and writeValue keep the regular
// separator formatting. The early return leaves every existing separator
// path byte-identical when the option is off.
if (this.typeFromDecimals &&
Number.isFinite(precision) &&
precision > 0 &&
!justPasted &&
!this.writingValue) {
result = this._formatFromDecimals(processedValue, precision, decimalMarker);
this._shift.clear();
const res = result.includes(MaskExpression.MINUS) && this.prefix && this.allowNegativeNumbers
? `${MaskExpression.MINUS}${this.prefix}${result
.split(MaskExpression.MINUS)
.join(MaskExpression.EMPTY_STRING)}${this.suffix}`
: result.length
? `${this.prefix}${result}${this.suffix}`
: this.instantPrefix
? this.prefix
: MaskExpression.EMPTY_STRING;
// Pin the caret to the end of the value part: every keystroke reshapes
// the whole string, so the in-place caret position is meaningless here.
cb(res.length - this.suffix.length - processedPosition, true);
state.earlyReturn = res;
return state;
}
// Issue #1250: typing an additional decimal marker into a value that
// already contains one must be a no-op — otherwise everything after the
// new marker is re-parsed as a fresh decimal part and the value is
// mangled (15.000,53 + ',' typed after the '1' -> 1,5). Remove the newly
// typed marker (the char right before the caret) and step the caret back.
// Paste keeps its own semantics (#1547 below: last marker wins), and
// backspace/writeValue flows cannot introduce a new marker.
if (!justPasted && !backspaced && !this.writingValue) {
const isDecimalMarkerChar = (char) => !!char &&
char !== this.thousandSeparator &&
(Array.isArray(this.decimalMarker)
? this.decimalMarker.includes(char)
: char === this.decimalMarker);
const prefixOffset = startsWithPrefix && !prefixAlreadyRemovedByCaller ? this.prefix.length : 0;
const typedMarkerIndex = processedPosition - prefixOffset - 1;
if (typedMarkerIndex >= 0 && isDecimalMarkerChar(processedValue[typedMarkerIndex])) {
let markerCount = 0;
for (const char of processedValue) {
if (isDecimalMarkerChar(char)) {
markerCount++;
}
}
if (markerCount > 1) {
processedValue =
processedValue.slice(0, typedMarkerIndex) +
processedValue.slice(typedMarkerIndex + 1);
stepBack = true;
}
}
}
// Issue #1547: a pasted value may contain grouping separators that are
// also configured decimal markers (default decimalMarker is ['.', ',']),
// e.g. '1,234.56'. Only the last marker character can actually be the
// decimal marker — treat the earlier ones as thousand separators and
// strip them, otherwise formatting cuts the value off at the first one.
if (justPasted && Array.isArray(this.decimalMarker)) {
const markerPositions = [];
for (let i = 0; i < processedValue.length; i++) {
const char = processedValue[i];
if (char !== this.thousandSeparator &&
this.decimalMarker.includes(char)) {
markerPositions.push(i);
}
}
if (markerPositions.length > 1) {
const lastMarkerPosition = markerPositions[markerPositions.length - 1];
processedValue = processedValue
.split(MaskExpression.EMPTY_STRING)
.filter((_, index) => index === lastMarkerPosition || !markerPositions.includes(index))
.join(MaskExpression.EMPTY_STRING);
}
}
if (backspaced) {
const { decimalMarkerIndex, nonZeroIndex } = this._findFirstNonZeroAndDecimalIndex(processedValue, decimalMarker);
const zeroIndexMinus = processedValue[0] === MaskExpression.MINUS;
const zeroIndexDecimalMarker = processedValue[0] === decimalMarker;
const firstIndexDecimalMarker = processedValue[1] === decimalMarker;
// Issues #1355/#1578: an all-zero remainder (e.g. '00' from '500', '00,000'
// from '100,000') must keep its zeros, matching the ',000,000' case.
// Only collapse when nothing but a bare decimal marker (or '-.') remains.
if ((zeroIndexDecimalMarker && !nonZeroIndex) ||
(zeroIndexMinus && firstIndexDecimalMarker && !nonZeroIndex)) {
processedValue = MaskExpression.NUMBER_ZERO;
}
if (decimalMarkerIndex && nonZeroIndex && zeroIndexMinus && processedPosition === 1) {
if (decimalMarkerIndex < nonZeroIndex || decimalMarkerIndex > nonZeroIndex) {
processedValue = MaskExpression.MINUS + processedValue.slice(nonZeroIndex);
}
}
// Issue #1516: decimalMarkerIndex is 0 when the remainder starts with
// the decimal marker (',34' after deleting the integer part) — a truthy
// check would treat it as "no decimal marker" and slice the marker off.
if (decimalMarkerIndex === null && nonZeroIndex && processedValue.length > nonZeroIndex) {
processedValue = zeroIndexMinus
? MaskExpression.MINUS + processedValue.slice(nonZeroIndex)
: processedValue.slice(nonZeroIndex);
}
if (decimalMarkerIndex && nonZeroIndex && processedPosition === 0) {
if (decimalMarkerIndex < nonZeroIndex) {
processedValue = processedValue.slice(decimalMarkerIndex - 1);
}
if (decimalMarkerIndex > nonZeroIndex) {
processedValue = processedValue.slice(nonZeroIndex);
}
}
}
// Leading-zero stripping is a typing-time rule ('05' -> '5'). When backspaced,
// leading zeros before a non-zero digit were already removed above, and an
// all-zero remainder must keep its zeros (issues #1355/#1578).
if (precision === 0 && !backspaced) {
processedValue = this.allowNegativeNumbers
? processedValue.length > 2 &&
processedValue[0] === MaskExpression.MINUS &&
processedValue[1] === MaskExpression.NUMBER_ZERO &&
processedValue[2] !== this.thousandSeparator &&
processedValue[2] !== MaskExpression.COMMA &&
processedValue[2] !== MaskExpression.DOT
? '-' + processedValue.slice(2, processedValue.length)
: processedValue[0] === MaskExpression.NUMBER_ZERO &&
processedValue.length > 1 &&
processedValue[1] !== this.thousandSeparator &&
processedValue[1] !== MaskExpression.COMMA &&
processedValue[1] !== MaskExpression.DOT
? processedValue.slice(1, processedValue.length)
: processedValue
: processedValue.length > 1 &&
processedValue[0] === MaskExpression.NUMBER_ZERO &&
processedValue[1] !== this.thousandSeparator &&
processedValue[1] !== MaskExpression.COMMA &&
processedValue[1] !== MaskExpression.DOT
? processedValue.slice(1, processedValue.length)
: processedValue;
}
else {
if (processedValue[0] === decimalMarker && processedValue.length > 1 && !backspaced) {
processedValue =
MaskExpression.NUMBER_ZERO + processedValue.slice(0, processedValue.length + 1);
this.plusOnePosition = true;
}
if (processedValue[0] === MaskExpression.NUMBER_ZERO &&
processedValue[1] !== decimalMarker &&
processedValue[1] !== this.thousandSeparator &&
!backspaced) {
processedValue =
processedValue.length > 1
? processedValue.slice(0, 1) +
decimalMarker +
processedValue.slice(1, processedValue.length + 1)
: processedValue;
this.plusOnePosition = true;
}
if (this.allowNegativeNumbers &&
!backspaced &&
processedValue[0] === MaskExpression.MINUS &&
(processedValue[1] === decimalMarker ||
processedValue[1] === MaskExpression.NUMBER_ZERO)) {
processedValue =
processedValue[1] === decimalMarker && processedValue.length > 2
? processedValue.slice(0, 1) +
MaskExpression.NUMBER_ZERO +
processedValue.slice(1, processedValue.length)
: processedValue[1] === MaskExpression.NUMBER_ZERO &&
processedValue.length > 2 &&
processedValue[2] !== decimalMarker
? processedValue.slice(0, 2) +
decimalMarker +
processedValue.slice(2, processedValue.length)
: processedValue;
this.plusOnePosition = true;
}
}
// Historical note: pre-refactor code used different allowed-character regexes
// per config (plain separator: no COMMA; dot thousand-sep: no SPACE, COMMA OK;
// comma thousand-sep: no SPACE, COMMA OK). The unified regex below removes
// exactly the active thousandSeparator + decimalMarker(s) from the invalid set,
// so each config already accepts its own chars and rejects the others —
// verified by the "unified invalidChars regex" cases in separator.spec.ts.
const thousandSeparatorCharEscaped = this._charToRegExpExpression(this.thousandSeparator);
let invalidChars = '@#!$%^&*()_+|~=`{}\\[\\]:\\s,\\.";<>?\\/'.replace(thousandSeparatorCharEscaped, '');
//.replace(decimalMarkerEscaped, '');
if (Array.isArray(this.decimalMarker)) {
for (const marker of this.decimalMarker) {
invalidChars = invalidChars.replace(this._charToRegExpExpression(marker), MaskExpression.EMPTY_STRING);
}
}
else {
invalidChars = invalidChars.replace(this._charToRegExpExpression(this.decimalMarker), '');
}
const invalidCharRegexp = new RegExp('[' + invalidChars + ']');
if (processedValue.match(invalidCharRegexp)) {
processedValue = processedValue.substring(0, processedValue.length - 1);
}
processedValue = this.checkInputPrecision(processedValue, precision, this.decimalMarker);
const strForSep = processedValue.replace(new RegExp(thousandSeparatorCharEscaped, 'g'), '');
result = this._formatWithSeparators(strForSep, this.thousandSeparator, this.decimalMarker, precision);
const commaShift = result.indexOf(MaskExpression.COMMA) - processedValue.indexOf(MaskExpression.COMMA);
const shiftStep = result.length - processedValue.length;
const backspacedDecimalMarkerWithSeparatorLimit = backspaced && result.length < inputValue.length - this.suffix.length && this.separatorLimit;
if ((result[processedPosition - 1] === this.thousandSeparator ||
result[processedPosition - this.prefix.length]) &&
this.prefix &&
backspaced) {
processedPosition = processedPosition - 1;
}
else if ((shiftStep > 0 && result[processedPosition] !== this.thousandSeparator) ||
backspacedDecimalMarkerWithSeparatorLimit) {
backspaceShift = true;
let _shift = 0;
do {
this._shift.add(processedPosition + _shift);
_shift++;
} while (_shift < shiftStep);
}
else if (result[processedPosition - 1] === this.thousandSeparator ||
shiftStep === -4 ||
shiftStep === -3 ||
result[processedPosition] === this.thousandSeparator) {
this._shift.clear();
this._shift.add(processedPosition - 1);
}
else if ((commaShift !== 0 &&
processedPosition > 0 &&
!(result.indexOf(MaskExpression.COMMA) >= processedPosition && processedPosition > 3)) ||
(!(result.indexOf(MaskExpression.DOT) >= processedPosition && processedPosition > 3) &&
shiftStep <= 0)) {
this._shift.clear();
backspaceShift = true;
shift = shiftStep;
processedPosition += shiftStep;
this._shift.add(processedPosition);
}
else {
this._shift.clear();
}
state.processedValue = processedValue;
state.processedPosition = processedPosition;
state.result = result;
state.backspaceShift = backspaceShift;
state.shift = shift;
state.stepBack = stepBack;
return state;
};
const MASK_HANDLERS = [
{
id: 'ip',
terminal: false,
match: (maskExpression) => maskExpression === MaskExpression.IP,
handle: ipHandler,
},
{
id: 'cpf-cnpj',
terminal: false,
match: (maskExpression) => maskExpression === MaskExpression.CPF_CNPJ ||
maskExpression === MaskExpression.CPF_CNPJ_ALPHA,
handle: cpfCnpjHandler,
},
{
id: 'percent',
terminal: true,
match: (maskExpression) => maskExpression.startsWith(MaskExpression.PERCENT),
handle: percentHandler,
},
{
id: 'separator',
terminal: true,
match: (maskExpression) => maskExpression.startsWith(MaskExpression.SEPARATOR),
handle: separatorHandler,
},
];
/** Runs mask-type dispatch: first matching entry wins. IP/CPF_CNPJ pre-process then
* fall through to the generic loop; PERCENT/SEPARATOR resolve terminally; no match →
* generic loop directly (the original `else`). IP and CPF_CNPJ are mutually exclusive
* exact matches, so scanning the ordered table is behavior-identical to the original
* IP → CPF_CNPJ → PERCENT → SEPARATOR → else chain. */
function dispatchMaskHandler(self, state, params) {
for (const entry of MASK_HANDLERS) {
if (entry.match(state.maskExpression)) {
const next = entry.handle.call(self, state, params);
return entry.terminal ? next : genericPatternHandler.call(self, next, params);
}
}
return genericPatternHandler.call(self, state, params);
}
class NgxMaskApplierService {
_config = inject(NGX_MASK_CONFIG);
dropSpecialCharacters = this._config.dropSpecialCharacters;
hiddenInput = this._config.hiddenInput;
clearIfNotMatch = this._config.clearIfNotMatch;
specialCharacters = this._config.specialCharacters;
patterns = this._config.patterns;
prefix = this._config.prefix;
suffix = this._config.suffix;
thousandSeparator = this._config.thousandSeparator;
decimalMarker = this._config.decimalMarker;
customPattern;
showMaskTyped = this._config.showMaskTyped;
placeHolderCharacter = this._config.placeHolderCharacter;
validation = this._config.validation;
separatorLimit = this._config.separatorLimit;
allowNegativeNumbers = this._config.allowNegativeNumbers;
leadZeroDateTime = this._config.leadZeroDateTime;
leadZero = this._config.leadZero;
typeFromDecimals = this._config.typeFromDecimals;
apm = this._config.apm;
inputTransformFn = this._config.inputTransformFn;
outputTransformFn = this._config.outputTransformFn;
keepCharacterPositions = this._config.keepCharacterPositions;
instantPrefix = this._config.instantPrefix;
triggerOnMaskChange = this._config.triggerOnMaskChange;
_shift = new Set();
plusOnePosition = false;
maskExpression = '';
actualValue = '';
showKeepCharacterExp = '';
shownMaskExpression = this._config.shownMaskExpression;
deletedSpecialCharacter = false;
/**
* Whether we are currently in writeValue function, in this case when applying the mask we don't want to trigger onChange function,
* since writeValue should be a one way only process of writing the DOM value based on the Angular model value.
*/
writingValue = false;
ipError;
cpfCnpjError;
applyMask(inputValue, maskExpression, position = 0, justPasted = false, backspaced = false,
// eslint-disable-next-line @typescript-eslint/no-empty-function
cb = () => { }) {
if (!maskExpression || typeof inputValue !== 'string') {
return MaskExpression.EMPTY_STRING;
}
let cursor = 0;
let result = '';
const multi = false;
let backspaceShift = false;
let shift = 1;
let stepBack = false;
let processedValue = inputValue;
let processedPosition = position;
const startsWithPrefix = processedValue.slice(0, this.prefix.length) === this.prefix;
// On paste with showMaskTyped, NgxMaskService.applyMask has already removed the
// prefix via removeMask() before delegating here (unless the value consisted of
// the prefix alone, in which case the raw value falls through). Stripping again
// would eat leading characters that merely look like the prefix (#1551).
const prefixAlreadyRemovedByCaller = justPasted &&
this.showMaskTyped &&
this.placeHolderCharacter.length === 1 &&
!this.leadZeroDateTime &&
processedValue !== this.prefix;
if (startsWithPrefix && !prefixAlreadyRemovedByCaller) {
processedValue = processedValue.slice(this.prefix.length);
}
if (!!this.suffix && processedValue.length > 0) {
processedValue = this.checkAndRemoveSuffix(processedValue);
}
if (processedValue === '(' && this.prefix) {
processedValue = '';
}
const inputArray = processedValue.toString().split(MaskExpression.EMPTY_STRING);
if (this.allowNegativeNumbers &&
processedValue.slice(cursor, cursor + 1) === MaskExpression.MINUS) {
result += processedValue.slice(cursor, cursor + 1);
}
const arr = [];
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < processedValue.length; i++) {
if (processedValue[i]?.match('\\d')) {
arr.push(processedValue[i] ?? MaskExpression.EMPTY_STRING);
}
}
// Per-mask-type dispatch (IP/CPF_CNPJ pre-processors → generic loop; PERCENT and
// SEPARATOR terminal handlers; generic pattern loop as fallback). See
// ./mask-handlers/mask-handlers.registry.ts.
const state = {
processedValue,
processedPosition,
cursor,
result,
multi,
backspaceShift,
shift,
stepBack,
maskExpression,
};
const resolved = dispatchMaskHandler(this, state, {
inputValue,
position,
justPasted,
backspaced,
cb,
inputArray,
arr,
startsWithPrefix,
prefixAlreadyRemovedByCaller,
});
if (typeof resolved.earlyReturn === 'string') {
return resolved.earlyReturn;
}
processedValue = resolved.processedValue;
processedPosition = resolved.processedPosition;
cursor = resolved.cursor;
result = resolved.result;
backspaceShift = resolved.backspaceShift;
shift = resolved.shift;
stepBack = resolved.stepBack;
// eslint-disable-next-line no-param-reassign
maskExpression = resolved.maskExpression;
if (result[processedPosition - 1] &&
result.length + 1 === maskExpression.length &&
this.specialCharacters.indexOf(maskExpression[maskExpression.length - 1] ?? MaskExpression.EMPTY_STRING) !== -1) {
result += maskExpression[maskExpression.length - 1];
}
let newPosition = processedPosition + 1;
while (this._shift.has(newPosition)) {
shift++;
newPosition++;
}
let actualShift = justPasted && !maskExpression.startsWith(MaskExpression.SEPARATOR)
? cursor
: this._shift.has(processedPosition)
? shift
: 0;
if (stepBack) {
actualShift--;
}
cb(actualShift, backspaceShift);
if (shift < 0) {
this._shift.clear();
}
let onlySpecial = false;
if (backspaced