UNPKG

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
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