ngx-mask 21.0.1 → 21.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/ngx-mask.mjs +1033 -223
- package/fesm2022/ngx-mask.mjs.map +1 -1
- package/package.json +3 -2
- package/types/ngx-mask.d.ts +194 -19
package/fesm2022/ngx-mask.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, EventEmitter, inject, Injectable, ElementRef, Renderer2, makeEnvironmentProviders, input, model, output,
|
|
2
|
+
import { InjectionToken, EventEmitter, inject, Injectable, signal, ElementRef, Renderer2, makeEnvironmentProviders, input, model, booleanAttribute, output, ChangeDetectorRef, Injector, effect, untracked, HostListener, Directive, Pipe } from '@angular/core';
|
|
3
3
|
import { DOCUMENT } from '@angular/common';
|
|
4
|
-
import { NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
|
|
4
|
+
import { NgControl, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
|
|
5
5
|
|
|
6
6
|
var MaskExpression;
|
|
7
7
|
(function (MaskExpression) {
|
|
@@ -9,6 +9,7 @@ var MaskExpression;
|
|
|
9
9
|
MaskExpression["PERCENT"] = "percent";
|
|
10
10
|
MaskExpression["IP"] = "IP";
|
|
11
11
|
MaskExpression["CPF_CNPJ"] = "CPF_CNPJ";
|
|
12
|
+
MaskExpression["CPF_CNPJ_ALPHA"] = "CPF_CNPJ_ALPHA";
|
|
12
13
|
MaskExpression["MONTH"] = "M";
|
|
13
14
|
MaskExpression["MONTHS"] = "M0";
|
|
14
15
|
MaskExpression["MINUTE"] = "m";
|
|
@@ -168,6 +169,11 @@ class NgxMaskApplierService {
|
|
|
168
169
|
showKeepCharacterExp = '';
|
|
169
170
|
shownMaskExpression = this._config.shownMaskExpression;
|
|
170
171
|
deletedSpecialCharacter = false;
|
|
172
|
+
/**
|
|
173
|
+
* Whether we are currently in writeValue function, in this case when applying the mask we don't want to trigger onChange function,
|
|
174
|
+
* since writeValue should be a one way only process of writing the DOM value based on the Angular model value.
|
|
175
|
+
*/
|
|
176
|
+
writingValue = false;
|
|
171
177
|
ipError;
|
|
172
178
|
cpfCnpjError;
|
|
173
179
|
applyMask(inputValue, maskExpression, position = 0, justPasted = false, backspaced = false,
|
|
@@ -185,9 +191,16 @@ class NgxMaskApplierService {
|
|
|
185
191
|
let processedValue = inputValue;
|
|
186
192
|
let processedPosition = position;
|
|
187
193
|
const startsWithPrefix = processedValue.slice(0, this.prefix.length) === this.prefix;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
194
|
+
// On paste with showMaskTyped, NgxMaskService.applyMask has already removed the
|
|
195
|
+
// prefix via removeMask() before delegating here (unless the value consisted of
|
|
196
|
+
// the prefix alone, in which case the raw value falls through). Stripping again
|
|
197
|
+
// would eat leading characters that merely look like the prefix (#1551).
|
|
198
|
+
const prefixAlreadyRemovedByCaller = justPasted &&
|
|
199
|
+
this.showMaskTyped &&
|
|
200
|
+
this.placeHolderCharacter.length === 1 &&
|
|
201
|
+
!this.leadZeroDateTime &&
|
|
202
|
+
processedValue !== this.prefix;
|
|
203
|
+
if (startsWithPrefix && !prefixAlreadyRemovedByCaller) {
|
|
191
204
|
processedValue = processedValue.slice(this.prefix.length);
|
|
192
205
|
}
|
|
193
206
|
if (!!this.suffix && processedValue.length > 0) {
|
|
@@ -214,11 +227,17 @@ class NgxMaskApplierService {
|
|
|
214
227
|
arr.push(processedValue[i] ?? MaskExpression.EMPTY_STRING);
|
|
215
228
|
}
|
|
216
229
|
}
|
|
217
|
-
|
|
230
|
+
const isCpfCnpjAlpha = maskExpression === MaskExpression.CPF_CNPJ_ALPHA;
|
|
231
|
+
if (maskExpression === MaskExpression.CPF_CNPJ || isCpfCnpjAlpha) {
|
|
218
232
|
this.cpfCnpjError = arr.length !== 11 && arr.length !== 14;
|
|
219
|
-
|
|
233
|
+
const valueHasAnyLetter = /[a-zA-Z]/.test(processedValue);
|
|
234
|
+
if (valueHasAnyLetter && isCpfCnpjAlpha) {
|
|
220
235
|
// eslint-disable-next-line no-param-reassign
|
|
221
|
-
maskExpression = '
|
|
236
|
+
maskExpression = 'AA.AAA.AAA/AAAA-00';
|
|
237
|
+
}
|
|
238
|
+
else if (arr.length > 11) {
|
|
239
|
+
// eslint-disable-next-line no-param-reassign
|
|
240
|
+
maskExpression = isCpfCnpjAlpha ? 'AA.AAA.AAA/AAAA-00' : '00.000.000/0000-00';
|
|
222
241
|
}
|
|
223
242
|
else {
|
|
224
243
|
// eslint-disable-next-line no-param-reassign
|
|
@@ -278,15 +297,38 @@ class NgxMaskApplierService {
|
|
|
278
297
|
decimalMarker = this.decimalMarker.find((dm) => dm !== this.thousandSeparator);
|
|
279
298
|
}
|
|
280
299
|
}
|
|
300
|
+
// Issue #1547: a pasted value may contain grouping separators that are
|
|
301
|
+
// also configured decimal markers (default decimalMarker is ['.', ',']),
|
|
302
|
+
// e.g. '1,234.56'. Only the last marker character can actually be the
|
|
303
|
+
// decimal marker — treat the earlier ones as thousand separators and
|
|
304
|
+
// strip them, otherwise formatting cuts the value off at the first one.
|
|
305
|
+
if (justPasted && Array.isArray(this.decimalMarker)) {
|
|
306
|
+
const markerPositions = [];
|
|
307
|
+
for (let i = 0; i < processedValue.length; i++) {
|
|
308
|
+
const char = processedValue[i];
|
|
309
|
+
if (char !== this.thousandSeparator &&
|
|
310
|
+
this.decimalMarker.includes(char)) {
|
|
311
|
+
markerPositions.push(i);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (markerPositions.length > 1) {
|
|
315
|
+
const lastMarkerPosition = markerPositions[markerPositions.length - 1];
|
|
316
|
+
processedValue = processedValue
|
|
317
|
+
.split(MaskExpression.EMPTY_STRING)
|
|
318
|
+
.filter((_, index) => index === lastMarkerPosition || !markerPositions.includes(index))
|
|
319
|
+
.join(MaskExpression.EMPTY_STRING);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
281
322
|
if (backspaced) {
|
|
282
323
|
const { decimalMarkerIndex, nonZeroIndex } = this._findFirstNonZeroAndDecimalIndex(processedValue, decimalMarker);
|
|
283
324
|
const zeroIndexMinus = processedValue[0] === MaskExpression.MINUS;
|
|
284
|
-
const zeroIndexNumberZero = processedValue[0] === MaskExpression.NUMBER_ZERO;
|
|
285
325
|
const zeroIndexDecimalMarker = processedValue[0] === decimalMarker;
|
|
286
326
|
const firstIndexDecimalMarker = processedValue[1] === decimalMarker;
|
|
327
|
+
// Issues #1355/#1578: an all-zero remainder (e.g. '00' from '500', '00,000'
|
|
328
|
+
// from '100,000') must keep its zeros, matching the ',000,000' case.
|
|
329
|
+
// Only collapse when nothing but a bare decimal marker (or '-.') remains.
|
|
287
330
|
if ((zeroIndexDecimalMarker && !nonZeroIndex) ||
|
|
288
|
-
(zeroIndexMinus && firstIndexDecimalMarker && !nonZeroIndex)
|
|
289
|
-
(zeroIndexNumberZero && !decimalMarkerIndex && !nonZeroIndex)) {
|
|
331
|
+
(zeroIndexMinus && firstIndexDecimalMarker && !nonZeroIndex)) {
|
|
290
332
|
processedValue = MaskExpression.NUMBER_ZERO;
|
|
291
333
|
}
|
|
292
334
|
if (decimalMarkerIndex &&
|
|
@@ -297,7 +339,12 @@ class NgxMaskApplierService {
|
|
|
297
339
|
processedValue = MaskExpression.MINUS + processedValue.slice(nonZeroIndex);
|
|
298
340
|
}
|
|
299
341
|
}
|
|
300
|
-
|
|
342
|
+
// Issue #1516: decimalMarkerIndex is 0 when the remainder starts with
|
|
343
|
+
// the decimal marker (',34' after deleting the integer part) — a truthy
|
|
344
|
+
// check would treat it as "no decimal marker" and slice the marker off.
|
|
345
|
+
if (decimalMarkerIndex === null &&
|
|
346
|
+
nonZeroIndex &&
|
|
347
|
+
processedValue.length > nonZeroIndex) {
|
|
301
348
|
processedValue = zeroIndexMinus
|
|
302
349
|
? MaskExpression.MINUS + processedValue.slice(nonZeroIndex)
|
|
303
350
|
: processedValue.slice(nonZeroIndex);
|
|
@@ -311,7 +358,10 @@ class NgxMaskApplierService {
|
|
|
311
358
|
}
|
|
312
359
|
}
|
|
313
360
|
}
|
|
314
|
-
|
|
361
|
+
// Leading-zero stripping is a typing-time rule ('05' -> '5'). When backspaced,
|
|
362
|
+
// leading zeros before a non-zero digit were already removed above, and an
|
|
363
|
+
// all-zero remainder must keep its zeros (issues #1355/#1578).
|
|
364
|
+
if (precision === 0 && !backspaced) {
|
|
315
365
|
processedValue = this.allowNegativeNumbers
|
|
316
366
|
? processedValue.length > 2 &&
|
|
317
367
|
processedValue[0] === MaskExpression.MINUS &&
|
|
@@ -531,23 +581,39 @@ class NgxMaskApplierService {
|
|
|
531
581
|
const inputValueSliceMinusOnePlusOne = processedValue.slice(cursor - 1, cursor + 1);
|
|
532
582
|
const inputValueSliceCursorPlusTwo = processedValue.slice(cursor, cursor + 2);
|
|
533
583
|
const inputValueSliceMinusTwoCursor = processedValue.slice(cursor - 2, cursor);
|
|
584
|
+
// Issue #1523: when the date token directly abuts a plain digit
|
|
585
|
+
// token (year-first masks without separators like 00M0d0 or
|
|
586
|
+
// 0000M0d0), the backward-looking windows below read the previous
|
|
587
|
+
// field's digits (year) as day/month digits and skip valid input.
|
|
588
|
+
// Disable only those backward heuristics in that layout.
|
|
589
|
+
const tokenAbutsDigitField = maskExpression[cursor - 1] === MaskExpression.NUMBER_ZERO;
|
|
534
590
|
if (maskExpression[cursor] === MaskExpression.DAY) {
|
|
535
591
|
const maskStartWithMonth = maskExpression.slice(0, 2) === MaskExpression.MONTHS;
|
|
536
592
|
const startWithMonthInput = maskExpression.slice(0, 2) === MaskExpression.MONTHS &&
|
|
537
593
|
this.specialCharacters.includes(inputValueCursorMinusTwo);
|
|
594
|
+
// Issue #1611: `cursor` indexes the mask, `i` indexes the input.
|
|
595
|
+
// On paste/writeValue of a bare digit string the cursor runs
|
|
596
|
+
// ahead of the input by every emitted separator, so cursor-based
|
|
597
|
+
// slices read year digits instead of the day — anchor the day
|
|
598
|
+
// window on the input index in those flows. Keystroke flows keep
|
|
599
|
+
// the historical cursor anchor (with showMaskTyped the value also
|
|
600
|
+
// carries placeholder chars, where the input anchor misreads).
|
|
601
|
+
const dayWindowStart = justPasted || this.writingValue ? i : cursor;
|
|
602
|
+
const dayWindowSlice = processedValue.slice(dayWindowStart, dayWindowStart + 2);
|
|
603
|
+
const dayWindowNext = processedValue[dayWindowStart + 1];
|
|
538
604
|
if ((Number(inputSymbol) > 3 && this.leadZeroDateTime) ||
|
|
539
605
|
(!maskStartWithMonth &&
|
|
540
606
|
(Number(inputValueSliceCursorPlusTwo) > daysCount ||
|
|
541
|
-
|
|
607
|
+
(!tokenAbutsDigitField &&
|
|
608
|
+
Number(inputValueSliceMinusOnePlusOne) > daysCount) ||
|
|
542
609
|
this.specialCharacters.includes(inputValueCursorPlusOne))) ||
|
|
543
610
|
(startWithMonthInput
|
|
544
611
|
? Number(inputValueSliceMinusOnePlusOne) > daysCount ||
|
|
545
612
|
(!this.specialCharacters.includes(inputValueCursor) &&
|
|
546
613
|
this.specialCharacters.includes(inputValueCursorPlusTwo)) ||
|
|
547
614
|
this.specialCharacters.includes(inputValueCursor)
|
|
548
|
-
: Number(
|
|
549
|
-
(this.specialCharacters.includes(
|
|
550
|
-
!backspaced))) {
|
|
615
|
+
: Number(dayWindowSlice) > daysCount ||
|
|
616
|
+
(this.specialCharacters.includes(dayWindowNext) && !backspaced))) {
|
|
551
617
|
processedPosition = !this.leadZeroDateTime
|
|
552
618
|
? processedPosition + 1
|
|
553
619
|
: processedPosition;
|
|
@@ -577,7 +643,8 @@ class NgxMaskApplierService {
|
|
|
577
643
|
!this.specialCharacters.includes(inputValueCursor)) ||
|
|
578
644
|
this.specialCharacters.includes(inputValueCursor));
|
|
579
645
|
// month<12 && day<10 for input
|
|
580
|
-
const day2monthInput =
|
|
646
|
+
const day2monthInput = !tokenAbutsDigitField &&
|
|
647
|
+
Number(inputValueSliceMinusThreeMinusOne) <= daysCount &&
|
|
581
648
|
!this.specialCharacters.includes(inputValueSliceMinusThreeMinusOne) &&
|
|
582
649
|
this.specialCharacters.includes(inputValueCursorMinusOne) &&
|
|
583
650
|
(Number(inputValueSliceCursorPlusTwo) > monthsCount ||
|
|
@@ -587,13 +654,15 @@ class NgxMaskApplierService {
|
|
|
587
654
|
(this.specialCharacters.includes(inputValueCursorPlusOne) &&
|
|
588
655
|
cursor === 5);
|
|
589
656
|
// // day<10 && month<12 for paste whole data
|
|
590
|
-
const day1monthPaste =
|
|
657
|
+
const day1monthPaste = !tokenAbutsDigitField &&
|
|
658
|
+
Number(inputValueSliceMinusThreeMinusOne) > daysCount &&
|
|
591
659
|
!this.specialCharacters.includes(inputValueSliceMinusThreeMinusOne) &&
|
|
592
660
|
!this.specialCharacters.includes(inputValueSliceMinusTwoCursor) &&
|
|
593
661
|
Number(inputValueSliceMinusTwoCursor) > monthsCount &&
|
|
594
662
|
maskExpression.includes('d0');
|
|
595
663
|
// 10<day<31 && month<12 for paste whole data
|
|
596
|
-
const day2monthPaste =
|
|
664
|
+
const day2monthPaste = !tokenAbutsDigitField &&
|
|
665
|
+
Number(inputValueSliceMinusThreeMinusOne) <= daysCount &&
|
|
597
666
|
!this.specialCharacters.includes(inputValueSliceMinusThreeMinusOne) &&
|
|
598
667
|
!this.specialCharacters.includes(inputValueCursorMinusOne) &&
|
|
599
668
|
Number(inputValueSliceMinusOnePlusOne) > monthsCount;
|
|
@@ -659,15 +728,22 @@ class NgxMaskApplierService {
|
|
|
659
728
|
i--;
|
|
660
729
|
}
|
|
661
730
|
else if (this.maskExpression[cursor + 1] === MaskExpression.SYMBOL_STAR &&
|
|
662
|
-
|
|
663
|
-
|
|
731
|
+
// A typed char that exactly matches the mask's literal char at cursor+2
|
|
732
|
+
// (e.g. the '@' in 'A*@A*.A*') always terminates the 'A*' run, regardless
|
|
733
|
+
// of whether that literal is registered in `specialCharacters` — an
|
|
734
|
+
// explicitly empty `specialCharacters` list must not make mask literals
|
|
735
|
+
// unmatchable (#1512).
|
|
736
|
+
(this._findSpecialChar(this.maskExpression[cursor + 2] ?? MaskExpression.EMPTY_STRING)
|
|
737
|
+
? this._findSpecialChar(inputSymbol) === this.maskExpression[cursor + 2]
|
|
738
|
+
: inputSymbol === this.maskExpression[cursor + 2]) &&
|
|
664
739
|
multi) {
|
|
665
740
|
cursor += 3;
|
|
666
741
|
result += inputSymbol;
|
|
667
742
|
}
|
|
668
743
|
else if (this.maskExpression[cursor + 1] === MaskExpression.SYMBOL_QUESTION &&
|
|
669
|
-
this._findSpecialChar(this.maskExpression[cursor + 2] ?? MaskExpression.EMPTY_STRING)
|
|
670
|
-
|
|
744
|
+
(this._findSpecialChar(this.maskExpression[cursor + 2] ?? MaskExpression.EMPTY_STRING)
|
|
745
|
+
? this._findSpecialChar(inputSymbol) === this.maskExpression[cursor + 2]
|
|
746
|
+
: inputSymbol === this.maskExpression[cursor + 2]) &&
|
|
671
747
|
multi) {
|
|
672
748
|
cursor += 3;
|
|
673
749
|
result += inputSymbol;
|
|
@@ -713,9 +789,20 @@ class NgxMaskApplierService {
|
|
|
713
789
|
const isSpecialCharacterMaskFirstSymbol = processedValue.length === 1 &&
|
|
714
790
|
this.specialCharacters.includes(maskExpression[0]) &&
|
|
715
791
|
processedValue !== maskExpression[0];
|
|
716
|
-
if (
|
|
717
|
-
|
|
718
|
-
|
|
792
|
+
if (isSpecialCharacterMaskFirstSymbol) {
|
|
793
|
+
// The mask may start with several literal special characters in a row
|
|
794
|
+
// (e.g. '+(000) 000-0000'). A single typed character must be checked
|
|
795
|
+
// against the first PATTERN position of the mask, not literally against
|
|
796
|
+
// index 1 — otherwise a valid digit is rejected and the leading literals
|
|
797
|
+
// are never auto-filled (#1498).
|
|
798
|
+
let firstPatternIndex = 1;
|
|
799
|
+
while (firstPatternIndex < maskExpression.length &&
|
|
800
|
+
this.specialCharacters.includes(maskExpression[firstPatternIndex])) {
|
|
801
|
+
firstPatternIndex++;
|
|
802
|
+
}
|
|
803
|
+
if (!this._checkSymbolMask(processedValue, maskExpression[firstPatternIndex] ?? MaskExpression.EMPTY_STRING)) {
|
|
804
|
+
return '';
|
|
805
|
+
}
|
|
719
806
|
}
|
|
720
807
|
if (result.includes(MaskExpression.MINUS) && this.prefix && this.allowNegativeNumbers) {
|
|
721
808
|
if (backspaced && result === MaskExpression.MINUS) {
|
|
@@ -794,11 +881,21 @@ class NgxMaskApplierService {
|
|
|
794
881
|
checkAndRemoveSuffix = (inputValue) => {
|
|
795
882
|
for (let i = this.suffix?.length - 1; i >= 0; i--) {
|
|
796
883
|
const substr = this.suffix.substring(i, this.suffix?.length);
|
|
797
|
-
if (inputValue.
|
|
884
|
+
if (inputValue.endsWith(substr) &&
|
|
798
885
|
i !== this.suffix?.length - 1 &&
|
|
886
|
+
// A partial suffix tail (i > 0) that makes up the WHOLE value is a
|
|
887
|
+
// leftover of the displayed suffix only when the previous rendered
|
|
888
|
+
// value ended with the suffix AND the edit shrank the value to (or
|
|
889
|
+
// below) the old value-part length, i.e. it was a deletion of the
|
|
890
|
+
// suffix head. Otherwise it is fresh user input that merely collides
|
|
891
|
+
// with the suffix text and must be kept (#1495).
|
|
892
|
+
(i === 0 ||
|
|
893
|
+
inputValue.length > substr.length ||
|
|
894
|
+
(this.actualValue.endsWith(this.suffix) &&
|
|
895
|
+
inputValue.length <= this.actualValue.length - this.suffix.length)) &&
|
|
799
896
|
(i - 1 < 0 ||
|
|
800
|
-
!inputValue.
|
|
801
|
-
return inputValue.
|
|
897
|
+
!inputValue.endsWith(this.suffix.substring(i - 1, this.suffix?.length)))) {
|
|
898
|
+
return inputValue.slice(0, inputValue.length - substr.length);
|
|
802
899
|
}
|
|
803
900
|
}
|
|
804
901
|
return inputValue;
|
|
@@ -912,10 +1009,10 @@ class NgxMaskApplierService {
|
|
|
912
1009
|
nonZeroIndex,
|
|
913
1010
|
};
|
|
914
1011
|
}
|
|
915
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
916
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.
|
|
1012
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskApplierService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1013
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskApplierService });
|
|
917
1014
|
}
|
|
918
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
1015
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskApplierService, decorators: [{
|
|
919
1016
|
type: Injectable
|
|
920
1017
|
}] });
|
|
921
1018
|
|
|
@@ -928,12 +1025,20 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
928
1025
|
maskExpressionArray = [];
|
|
929
1026
|
previousValue = '';
|
|
930
1027
|
currentValue = '';
|
|
1028
|
+
// `writingValue` is declared on NgxMaskApplierService: applyMask needs it to
|
|
1029
|
+
// distinguish the writeValue flow from keystroke flows (#1611).
|
|
1030
|
+
isInitialized = false;
|
|
931
1031
|
/**
|
|
932
|
-
*
|
|
933
|
-
*
|
|
1032
|
+
* Set by the directive's keepCharacterPositions handling for the current edit:
|
|
1033
|
+
* true — the directive fully resolved the resulting display value into actualValue,
|
|
1034
|
+
* so applyMask must short-circuit and render actualValue as-is;
|
|
1035
|
+
* false — the edit must flow through regular masking (no short-circuit);
|
|
1036
|
+
* null — the directive was not involved in this applyMask call (legacy behavior).
|
|
1037
|
+
* Consumed and reset by applyMask. This lets keepCharacterPositions work without
|
|
1038
|
+
* showMaskTyped (#1545, #1543).
|
|
934
1039
|
*/
|
|
935
|
-
|
|
936
|
-
|
|
1040
|
+
keepCharacterPositionsHandled = null;
|
|
1041
|
+
_isFocused = signal(false, ...(ngDevMode ? [{ debugName: "_isFocused" }] : /* istanbul ignore next */ []));
|
|
937
1042
|
_emitValue = false;
|
|
938
1043
|
_start;
|
|
939
1044
|
_end;
|
|
@@ -956,10 +1061,35 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
956
1061
|
applyMask(inputValue, maskExpression, position = 0, justPasted = false, backspaced = false,
|
|
957
1062
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
958
1063
|
cb = () => { }) {
|
|
1064
|
+
// Consume the directive's keepCharacterPositions verdict for this edit (see the
|
|
1065
|
+
// keepCharacterPositionsHandled doc). Reset immediately so it never leaks into
|
|
1066
|
+
// applyMask calls that do not originate from the directive's input handling.
|
|
1067
|
+
const kcpHandled = this.keepCharacterPositionsHandled;
|
|
1068
|
+
this.keepCharacterPositionsHandled = null;
|
|
959
1069
|
// If no mask expression, return the input value or the actual value
|
|
960
1070
|
if (!maskExpression) {
|
|
961
1071
|
return inputValue !== this.actualValue ? this.actualValue : inputValue;
|
|
962
1072
|
}
|
|
1073
|
+
// #1492: a numeric FormControl value like 0.0000007 or 1e21 stringifies to
|
|
1074
|
+
// exponential notation ('7e-7'); separator masking would strip the 'e'/'-' and
|
|
1075
|
+
// render garbage ('77'). Expand scientific notation to plain decimal form before
|
|
1076
|
+
// masking. Covers writeValue-driven flows and the pipe; a keystroke value can
|
|
1077
|
+
// never match, since separator masking never lets a letter through.
|
|
1078
|
+
if (maskExpression.startsWith(MaskExpression.SEPARATOR) &&
|
|
1079
|
+
inputValue &&
|
|
1080
|
+
/\d[eE][+-]?\d/.test(inputValue)) {
|
|
1081
|
+
const expandedNumber = Number(this._replaceDecimalMarkerToDot(inputValue));
|
|
1082
|
+
if (!Number.isNaN(expandedNumber)) {
|
|
1083
|
+
// eslint-disable-next-line no-param-reassign
|
|
1084
|
+
inputValue = this._toPlainDecimalString(expandedNumber);
|
|
1085
|
+
if (this.decimalMarker === MaskExpression.COMMA ||
|
|
1086
|
+
(Array.isArray(this.decimalMarker) &&
|
|
1087
|
+
this.thousandSeparator === MaskExpression.DOT)) {
|
|
1088
|
+
// eslint-disable-next-line no-param-reassign
|
|
1089
|
+
inputValue = inputValue.replace(MaskExpression.DOT, MaskExpression.COMMA);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
963
1093
|
// Show mask in input if required
|
|
964
1094
|
this.maskIsShown = this.showMaskTyped
|
|
965
1095
|
? this.showMaskInInput()
|
|
@@ -968,7 +1098,9 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
968
1098
|
if (this.maskExpression === MaskExpression.IP && this.showMaskTyped) {
|
|
969
1099
|
this.maskIsShown = this.showMaskInInput(inputValue || MaskExpression.HASH);
|
|
970
1100
|
}
|
|
971
|
-
|
|
1101
|
+
const isCpfCnpjMask = this.maskExpression === MaskExpression.CPF_CNPJ ||
|
|
1102
|
+
this.maskExpression === MaskExpression.CPF_CNPJ_ALPHA;
|
|
1103
|
+
if (isCpfCnpjMask && this.showMaskTyped) {
|
|
972
1104
|
this.maskIsShown = this.showMaskInInput(inputValue || MaskExpression.HASH);
|
|
973
1105
|
}
|
|
974
1106
|
// Handle empty input value with mask typed
|
|
@@ -985,7 +1117,11 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
985
1117
|
if ((this.hiddenInput ||
|
|
986
1118
|
(inputValue && inputValue.indexOf(MaskExpression.SYMBOL_STAR) >= 0)) &&
|
|
987
1119
|
!this.writingValue) {
|
|
988
|
-
|
|
1120
|
+
// Seed from the raw input only when the user typed the FIRST character (the
|
|
1121
|
+
// single char is the raw symbol, actualValue is empty/stale). On backspace a
|
|
1122
|
+
// length-1 value is the remaining masked display (e.g. '*'), so the hidden
|
|
1123
|
+
// characters must come from actualValue or the deletion destroys both (#1612).
|
|
1124
|
+
let actualResult = inputValue && inputValue.length === 1 && !backspaced
|
|
989
1125
|
? inputValue.split(MaskExpression.EMPTY_STRING)
|
|
990
1126
|
: this.actualValue.split(MaskExpression.EMPTY_STRING);
|
|
991
1127
|
// Handle backspace
|
|
@@ -1085,8 +1221,10 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1085
1221
|
newInputValue =
|
|
1086
1222
|
Boolean(newInputValue) && newInputValue.length ? newInputValue : inputValue;
|
|
1087
1223
|
}
|
|
1088
|
-
// Handle
|
|
1089
|
-
|
|
1224
|
+
// Handle keepCharacterPositions: when the directive resolved the display for this
|
|
1225
|
+
// edit (kcpHandled === true), or legacily whenever showMaskTyped is on and the
|
|
1226
|
+
// directive gave no explicit verdict, render actualValue as-is.
|
|
1227
|
+
if ((kcpHandled ?? this.showMaskTyped) &&
|
|
1090
1228
|
this.keepCharacterPositions &&
|
|
1091
1229
|
this.actualValue &&
|
|
1092
1230
|
!justPasted &&
|
|
@@ -1101,6 +1239,27 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1101
1239
|
}
|
|
1102
1240
|
// Apply the mask using the parent class method
|
|
1103
1241
|
const result = super.applyMask(newInputValue, maskExpression, newPosition, justPasted, backspaced, cb);
|
|
1242
|
+
// #1615: a value the mask cannot process AT ALL (masking leaves nothing of it) is
|
|
1243
|
+
// rendered verbatim instead of being destroyed, and must not reach formControlResult
|
|
1244
|
+
// — the empty remnant would clobber the model that holds the sentinel (e.g.
|
|
1245
|
+
// setValue('ONGOING') on a digits mask). Values that PARTIALLY match keep regular
|
|
1246
|
+
// masking. Two cases reach this:
|
|
1247
|
+
// 1. this.writingValue: the originating writeValue() call itself.
|
|
1248
|
+
// 2. currentValue === inputValue (and the mask didn't just change): a LATER re-render
|
|
1249
|
+
// replaying the exact same already-verbatim value outside of writeValue (e.g.
|
|
1250
|
+
// ngOnChanges re-running applyMask via the directive's _applyMask() when an
|
|
1251
|
+
// UNRELATED input like `disabled` changes) — without this, that pass would fall
|
|
1252
|
+
// through to regular masking, emit the empty result via onChange, and clobber the
|
|
1253
|
+
// model even though the DOM would still show the sentinel correctly.
|
|
1254
|
+
if ((this.writingValue || (!this.maskChanged && inputValue === this.currentValue)) &&
|
|
1255
|
+
inputValue &&
|
|
1256
|
+
!result &&
|
|
1257
|
+
this.removeMask(inputValue)) {
|
|
1258
|
+
this.actualValue = inputValue;
|
|
1259
|
+
this.previousValue = this.currentValue;
|
|
1260
|
+
this.currentValue = inputValue;
|
|
1261
|
+
return inputValue;
|
|
1262
|
+
}
|
|
1104
1263
|
this.actualValue = this.getActualValue(result);
|
|
1105
1264
|
// handle some separator implications:
|
|
1106
1265
|
// a.) adjust decimalMarker default (. -> ,) if thousandSeparator is a dot
|
|
@@ -1144,14 +1303,19 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1144
1303
|
const resLen = result.length;
|
|
1145
1304
|
const prefNmask = `${this.prefix}${this.maskIsShown}${this.suffix}`;
|
|
1146
1305
|
// Handle specific mask expressions
|
|
1147
|
-
|
|
1306
|
+
// NOTE: IP/CPF_CNPJ/CPF_CNPJ_ALPHA are checked via exact equality BEFORE the HOURS
|
|
1307
|
+
// substring check below, because MaskExpression.HOURS is the single character 'H' and
|
|
1308
|
+
// 'CPF_CNPJ_ALPHA' contains 'H' (from "ALPHA"), which previously caused CPF_CNPJ_ALPHA
|
|
1309
|
+
// to be misrouted into the HOURS branch instead of its own branch.
|
|
1310
|
+
if (this.maskExpression === MaskExpression.IP ||
|
|
1311
|
+
this.maskExpression === MaskExpression.CPF_CNPJ ||
|
|
1312
|
+
this.maskExpression === MaskExpression.CPF_CNPJ_ALPHA) {
|
|
1313
|
+
return `${result}${prefNmask}`;
|
|
1314
|
+
}
|
|
1315
|
+
else if (this.maskExpression.includes(MaskExpression.HOURS)) {
|
|
1148
1316
|
const countSkipedSymbol = this._numberSkipedSymbols(result);
|
|
1149
1317
|
return `${result}${prefNmask.slice(resLen + countSkipedSymbol)}`;
|
|
1150
1318
|
}
|
|
1151
|
-
else if (this.maskExpression === MaskExpression.IP ||
|
|
1152
|
-
this.maskExpression === MaskExpression.CPF_CNPJ) {
|
|
1153
|
-
return `${result}${prefNmask}`;
|
|
1154
|
-
}
|
|
1155
1319
|
return `${result}${prefNmask.slice(resLen)}`;
|
|
1156
1320
|
}
|
|
1157
1321
|
// get the number of characters that were shifted
|
|
@@ -1241,12 +1405,34 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1241
1405
|
String(value).length > 14)) {
|
|
1242
1406
|
return String(value);
|
|
1243
1407
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1408
|
+
// #1573: toLocaleString('fullwide', ...) is NOT locale-independent — 'fullwide' is
|
|
1409
|
+
// not a real locale tag, so Intl silently falls back to the runtime DEFAULT locale
|
|
1410
|
+
// (e.g. de-AT on Edge with Austrian regional format emits '0,5'). Expand exponential
|
|
1411
|
+
// notation with pure string math instead; the decimal marker is always '.'.
|
|
1412
|
+
return this._toPlainDecimalString(Number(value));
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Locale-independent replacement for toLocaleString('fullwide', { useGrouping: false,
|
|
1416
|
+
* maximumFractionDigits: 20 }) (#1573): expands exponential notation ('7e-7', '1e+21')
|
|
1417
|
+
* to plain decimal form using '.' as decimal marker, regardless of the runtime locale.
|
|
1418
|
+
*/
|
|
1419
|
+
_toPlainDecimalString(value) {
|
|
1420
|
+
const stringValue = String(value);
|
|
1421
|
+
const match = /^(-?)(\d+)(?:\.(\d+))?[eE]([+-]?\d+)$/.exec(stringValue);
|
|
1422
|
+
if (!match) {
|
|
1423
|
+
return stringValue;
|
|
1424
|
+
}
|
|
1425
|
+
const [, sign, integerPart, fractionPart = '', exponentPart] = match;
|
|
1426
|
+
const exponent = Number(exponentPart);
|
|
1427
|
+
const digits = `${integerPart}${fractionPart}`;
|
|
1428
|
+
const pointIndex = integerPart.length + exponent;
|
|
1429
|
+
if (pointIndex <= 0) {
|
|
1430
|
+
return `${sign}0.${'0'.repeat(-pointIndex)}${digits}`;
|
|
1431
|
+
}
|
|
1432
|
+
if (pointIndex >= digits.length) {
|
|
1433
|
+
return `${sign}${digits}${'0'.repeat(pointIndex - digits.length)}`;
|
|
1434
|
+
}
|
|
1435
|
+
return `${sign}${digits.slice(0, pointIndex)}.${digits.slice(pointIndex)}`;
|
|
1250
1436
|
}
|
|
1251
1437
|
showMaskInInput(inputVal) {
|
|
1252
1438
|
if (this.showMaskTyped && !!this.shownMaskExpression) {
|
|
@@ -1262,7 +1448,8 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1262
1448
|
if (this.maskExpression === MaskExpression.IP) {
|
|
1263
1449
|
return this._checkForIp(inputVal);
|
|
1264
1450
|
}
|
|
1265
|
-
if (this.maskExpression === MaskExpression.CPF_CNPJ
|
|
1451
|
+
if (this.maskExpression === MaskExpression.CPF_CNPJ ||
|
|
1452
|
+
this.maskExpression === MaskExpression.CPF_CNPJ_ALPHA) {
|
|
1266
1453
|
return this._checkForCpfCnpj(inputVal);
|
|
1267
1454
|
}
|
|
1268
1455
|
}
|
|
@@ -1348,28 +1535,41 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1348
1535
|
if (inputVal === MaskExpression.HASH) {
|
|
1349
1536
|
return cpf;
|
|
1350
1537
|
}
|
|
1351
|
-
const
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1538
|
+
const isCpfCnpjAlpha = this.maskExpression === MaskExpression.CPF_CNPJ_ALPHA;
|
|
1539
|
+
const hasAnyLetter = /[a-zA-Z]/.test(inputVal);
|
|
1540
|
+
const arr = this._countCpfCnpjTypedChars(inputVal, isCpfCnpjAlpha);
|
|
1541
|
+
if (isCpfCnpjAlpha && hasAnyLetter) {
|
|
1542
|
+
// CNPJ_ALPHA shape is "AA.AAA.AAA/AAAA-00": separators fall after the 2nd, 5th,
|
|
1543
|
+
// 8th and 12th typed character, so the placeholder slice offset must account for
|
|
1544
|
+
// however many separators `result` has already passed through, mirroring the
|
|
1545
|
+
// numeric CPF/CNPJ bucketed offsets below.
|
|
1546
|
+
if (arr.length <= 2) {
|
|
1547
|
+
return cnpj.slice(arr.length, cnpj.length);
|
|
1357
1548
|
}
|
|
1358
|
-
if (
|
|
1359
|
-
arr.
|
|
1549
|
+
if (arr.length > 2 && arr.length <= 5) {
|
|
1550
|
+
return cnpj.slice(arr.length + 1, cnpj.length);
|
|
1360
1551
|
}
|
|
1552
|
+
if (arr.length > 5 && arr.length <= 8) {
|
|
1553
|
+
return cnpj.slice(arr.length + 2, cnpj.length);
|
|
1554
|
+
}
|
|
1555
|
+
if (arr.length > 8 && arr.length <= 12) {
|
|
1556
|
+
return cnpj.slice(arr.length + 3, cnpj.length);
|
|
1557
|
+
}
|
|
1558
|
+
return cnpj.slice(arr.length + 4, cnpj.length);
|
|
1361
1559
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1560
|
+
else {
|
|
1561
|
+
if (arr.length <= 3) {
|
|
1562
|
+
return cpf.slice(arr.length, cpf.length);
|
|
1563
|
+
}
|
|
1564
|
+
if (arr.length > 3 && arr.length <= 6) {
|
|
1565
|
+
return cpf.slice(arr.length + 1, cpf.length);
|
|
1566
|
+
}
|
|
1567
|
+
if (arr.length > 6 && arr.length <= 9) {
|
|
1568
|
+
return cpf.slice(arr.length + 2, cpf.length);
|
|
1569
|
+
}
|
|
1570
|
+
if (arr.length > 9 && arr.length < 11) {
|
|
1571
|
+
return cpf.slice(arr.length + 3, cpf.length);
|
|
1572
|
+
}
|
|
1373
1573
|
}
|
|
1374
1574
|
if (arr.length === 11) {
|
|
1375
1575
|
return '';
|
|
@@ -1385,6 +1585,22 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1385
1585
|
}
|
|
1386
1586
|
return '';
|
|
1387
1587
|
}
|
|
1588
|
+
/** Collects the characters counted as "typed" for CPF/CNPJ progress tracking: digits only
|
|
1589
|
+
* for the numeric mask, alphanumerics for CPF_CNPJ_ALPHA. */
|
|
1590
|
+
_countCpfCnpjTypedChars(inputVal, isCpfCnpjAlpha) {
|
|
1591
|
+
const arr = [];
|
|
1592
|
+
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
|
1593
|
+
for (let i = 0; i < inputVal.length; i++) {
|
|
1594
|
+
const value = inputVal[i] ?? MaskExpression.EMPTY_STRING;
|
|
1595
|
+
if (!value) {
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
if (isCpfCnpjAlpha ? value.match('[a-zA-Z0-9]') : value.match('\\d')) {
|
|
1599
|
+
arr.push(value);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return arr;
|
|
1603
|
+
}
|
|
1388
1604
|
/**
|
|
1389
1605
|
* Recursively determine the current active element by navigating the Shadow DOM until the Active Element is found.
|
|
1390
1606
|
*/
|
|
@@ -1413,6 +1629,20 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1413
1629
|
if (!this.isInitialized && this._emitValue) {
|
|
1414
1630
|
return;
|
|
1415
1631
|
}
|
|
1632
|
+
// #1519: a non-special placeHolderCharacter (e.g. 'X') is never something the user
|
|
1633
|
+
// typed — it's the showMaskTyped fill character for unfilled slots — so it must never
|
|
1634
|
+
// reach the model, regardless of dropSpecialCharacters. The default '_' placeholder is
|
|
1635
|
+
// already covered by specialCharacters/removeMask; a custom single-char placeholder
|
|
1636
|
+
// that isn't a special character is not, since none of the branches below include it
|
|
1637
|
+
// in their removal set.
|
|
1638
|
+
if (this.showMaskTyped &&
|
|
1639
|
+
this.placeHolderCharacter.length === 1 &&
|
|
1640
|
+
this.specialCharacters.indexOf(this.placeHolderCharacter) === -1) {
|
|
1641
|
+
// eslint-disable-next-line no-param-reassign
|
|
1642
|
+
inputValue = inputValue
|
|
1643
|
+
.split(this.placeHolderCharacter)
|
|
1644
|
+
.join(MaskExpression.EMPTY_STRING);
|
|
1645
|
+
}
|
|
1416
1646
|
if (Array.isArray(this.dropSpecialCharacters)) {
|
|
1417
1647
|
this.onChange(outputTransformFn(this._toNumber(this._checkSymbols(this._removeMask(this._removeSuffix(this._removePrefix(inputValue)), this.dropSpecialCharacters)))));
|
|
1418
1648
|
}
|
|
@@ -1545,17 +1775,59 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1545
1775
|
_checkPrecision(separatorExpression, separatorValue) {
|
|
1546
1776
|
const separatorPrecision = this.getPrecision(separatorExpression);
|
|
1547
1777
|
let value = separatorValue;
|
|
1548
|
-
if (separatorExpression.indexOf('2') > 0 ||
|
|
1549
|
-
(this.leadZero &&
|
|
1778
|
+
if ((separatorExpression.indexOf('2') > 0 && !this._isFocused()) ||
|
|
1779
|
+
(this.leadZero &&
|
|
1780
|
+
!this._isFocused() &&
|
|
1781
|
+
Number(separatorPrecision) > 0 &&
|
|
1782
|
+
Number.isFinite(separatorPrecision))) {
|
|
1550
1783
|
if (this.decimalMarker === MaskExpression.COMMA && this.leadZero) {
|
|
1551
1784
|
value = value.replace(',', '.');
|
|
1552
1785
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1786
|
+
const precision = this.leadZero ? Number(separatorPrecision) : 2;
|
|
1787
|
+
// #1567: Number(value).toFixed() corrupts values with more significant digits
|
|
1788
|
+
// than an IEEE-754 double can hold (e.g. '999999999999999.99' becomes
|
|
1789
|
+
// '1000000000000000.00'). Round such values textually instead; safe-range
|
|
1790
|
+
// values keep the original toFixed() semantics.
|
|
1791
|
+
if (this._exceedsDoublePrecision(value)) {
|
|
1792
|
+
return this._stringToFixed(value, precision);
|
|
1793
|
+
}
|
|
1794
|
+
return Number(value).toFixed(precision);
|
|
1556
1795
|
}
|
|
1557
1796
|
return this.numberToString(value);
|
|
1558
1797
|
}
|
|
1798
|
+
/**
|
|
1799
|
+
* True when the plain decimal string carries more significant digits than an IEEE-754
|
|
1800
|
+
* double can represent exactly (15 is the guaranteed round-trip digit count), meaning a
|
|
1801
|
+
* Number() round-trip would corrupt it (#1567).
|
|
1802
|
+
*/
|
|
1803
|
+
_exceedsDoublePrecision(value) {
|
|
1804
|
+
if (!/^-?\d+(\.\d+)?$/.test(value)) {
|
|
1805
|
+
return false;
|
|
1806
|
+
}
|
|
1807
|
+
const significantDigits = value.replace(/\D/g, '').replace(/^0+/, '');
|
|
1808
|
+
return significantDigits.length > 15;
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Exact string-based equivalent of Number.prototype.toFixed (round half away from zero)
|
|
1812
|
+
* for plain decimal strings beyond double precision (#1567).
|
|
1813
|
+
*/
|
|
1814
|
+
_stringToFixed(value, precision) {
|
|
1815
|
+
const isNegative = value.startsWith(MaskExpression.MINUS);
|
|
1816
|
+
const absValue = isNegative ? value.slice(1) : value;
|
|
1817
|
+
const [integerPart = '0', fractionPart = ''] = absValue.split(MaskExpression.DOT);
|
|
1818
|
+
const paddedFraction = fractionPart.padEnd(precision + 1, '0');
|
|
1819
|
+
const keptFraction = paddedFraction.slice(0, precision);
|
|
1820
|
+
const shouldRoundUp = (paddedFraction.charCodeAt(precision) || 0) >= 53; // '5'
|
|
1821
|
+
let scaled = BigInt(integerPart + keptFraction);
|
|
1822
|
+
if (shouldRoundUp) {
|
|
1823
|
+
scaled += 1n;
|
|
1824
|
+
}
|
|
1825
|
+
const digits = scaled.toString().padStart(precision + 1, '0');
|
|
1826
|
+
const sign = isNegative && scaled > 0n ? MaskExpression.MINUS : '';
|
|
1827
|
+
return precision > 0
|
|
1828
|
+
? `${sign}${digits.slice(0, -precision)}.${digits.slice(-precision)}`
|
|
1829
|
+
: `${sign}${digits}`;
|
|
1830
|
+
}
|
|
1559
1831
|
_repeatPatternSymbols(maskExp) {
|
|
1560
1832
|
return ((maskExp.match(/{[0-9]+}/) &&
|
|
1561
1833
|
maskExp
|
|
@@ -1582,13 +1854,25 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1582
1854
|
}, '')) ||
|
|
1583
1855
|
maskExp);
|
|
1584
1856
|
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Decimal marker of the value being normalized in writeValue/pipe flows.
|
|
1859
|
+
*
|
|
1860
|
+
* #1573: this used to return the RUNTIME default locale's decimal marker
|
|
1861
|
+
* ((1.1).toLocaleString().substring(1, 2)), which made mask behavior depend on the
|
|
1862
|
+
* OS/browser regional format: under a comma-decimal locale (e.g. Edge + Austrian
|
|
1863
|
+
* regional settings) a preformatted value like '10,000' (thousandSeparator ',')
|
|
1864
|
+
* had its ',' replaced by the configured '.' decimalMarker, corrupting the value
|
|
1865
|
+
* 1000x. JS number stringification (String(n)) always uses '.', and string values
|
|
1866
|
+
* are expected to use the configured markers — the runtime locale is never the
|
|
1867
|
+
* right source, so this is always '.'.
|
|
1868
|
+
*/
|
|
1585
1869
|
currentLocaleDecimalMarker() {
|
|
1586
|
-
return
|
|
1870
|
+
return MaskExpression.DOT;
|
|
1587
1871
|
}
|
|
1588
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
1589
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.
|
|
1872
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskService, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
|
|
1873
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskService });
|
|
1590
1874
|
}
|
|
1591
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
1875
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskService, decorators: [{
|
|
1592
1876
|
type: Injectable
|
|
1593
1877
|
}] });
|
|
1594
1878
|
|
|
@@ -1625,82 +1909,160 @@ function provideEnvironmentNgxMask(configValue) {
|
|
|
1625
1909
|
|
|
1626
1910
|
class NgxMaskDirective {
|
|
1627
1911
|
// ===== Mask Configuration Inputs =====
|
|
1628
|
-
mask = input('', ...(ngDevMode ? [{ debugName: "mask" }] : []));
|
|
1629
|
-
specialCharacters = input([], ...(ngDevMode ? [{ debugName: "specialCharacters" }] : []));
|
|
1630
|
-
patterns = input({}, ...(ngDevMode ? [{ debugName: "patterns" }] : []));
|
|
1631
|
-
prefix = input('', ...(ngDevMode ? [{ debugName: "prefix" }] : []));
|
|
1632
|
-
suffix = input('', ...(ngDevMode ? [{ debugName: "suffix" }] : []));
|
|
1633
|
-
thousandSeparator = input(' ', ...(ngDevMode ? [{ debugName: "thousandSeparator" }] : []));
|
|
1634
|
-
decimalMarker = input('.', ...(ngDevMode ? [{ debugName: "decimalMarker" }] : []));
|
|
1635
|
-
dropSpecialCharacters = input(null, ...(ngDevMode ? [{ debugName: "dropSpecialCharacters" }] : []));
|
|
1636
|
-
hiddenInput = input(null, ...(ngDevMode ? [{ debugName: "hiddenInput" }] : []));
|
|
1637
|
-
showMaskTyped = input(null, ...(ngDevMode ? [{ debugName: "showMaskTyped" }] : []));
|
|
1638
|
-
placeHolderCharacter = input(null, ...(ngDevMode ? [{ debugName: "placeHolderCharacter" }] : []));
|
|
1639
|
-
shownMaskExpression = input(null, ...(ngDevMode ? [{ debugName: "shownMaskExpression" }] : []));
|
|
1640
|
-
clearIfNotMatch = input(null, ...(ngDevMode ? [{ debugName: "clearIfNotMatch" }] : []));
|
|
1641
|
-
validation = input(null, ...(ngDevMode ? [{ debugName: "validation" }] : []));
|
|
1642
|
-
separatorLimit = input('', ...(ngDevMode ? [{ debugName: "separatorLimit" }] : []));
|
|
1643
|
-
allowNegativeNumbers = input(null, ...(ngDevMode ? [{ debugName: "allowNegativeNumbers" }] : []));
|
|
1644
|
-
leadZeroDateTime = input(null, ...(ngDevMode ? [{ debugName: "leadZeroDateTime" }] : []));
|
|
1645
|
-
leadZero = input(null, ...(ngDevMode ? [{ debugName: "leadZero" }] : []));
|
|
1646
|
-
triggerOnMaskChange = input(null, ...(ngDevMode ? [{ debugName: "triggerOnMaskChange" }] : []));
|
|
1647
|
-
apm = input(null, ...(ngDevMode ? [{ debugName: "apm" }] : []));
|
|
1648
|
-
inputTransformFn = input(null, ...(ngDevMode ? [{ debugName: "inputTransformFn" }] : []));
|
|
1649
|
-
outputTransformFn = input(null, ...(ngDevMode ? [{ debugName: "outputTransformFn" }] : []));
|
|
1650
|
-
keepCharacterPositions = input(null, ...(ngDevMode ? [{ debugName: "keepCharacterPositions" }] : []));
|
|
1651
|
-
instantPrefix = input(null, ...(ngDevMode ? [{ debugName: "instantPrefix" }] : []));
|
|
1652
|
-
value = model('', ...(ngDevMode ? [{ debugName: "value" }] : []));
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
touched = model(false, ...(ngDevMode ? [{ debugName: "touched" }] : []));
|
|
1656
|
-
dirty = input(false, ...(ngDevMode ? [{ debugName: "dirty" }] : []));
|
|
1657
|
-
invalid = input(false, ...(ngDevMode ? [{ debugName: "invalid" }] : []));
|
|
1658
|
-
pending = input(false, ...(ngDevMode ? [{ debugName: "pending" }] : []));
|
|
1659
|
-
readonly = input(false, ...(ngDevMode ? [{ debugName: "readonly" }] : []));
|
|
1660
|
-
required = input(false, ...(ngDevMode ? [{ debugName: "required" }] : []));
|
|
1661
|
-
name = input('', ...(ngDevMode ? [{ debugName: "name" }] : []));
|
|
1912
|
+
mask = input('', ...(ngDevMode ? [{ debugName: "mask" }] : /* istanbul ignore next */ []));
|
|
1913
|
+
specialCharacters = input([], ...(ngDevMode ? [{ debugName: "specialCharacters" }] : /* istanbul ignore next */ []));
|
|
1914
|
+
patterns = input({}, ...(ngDevMode ? [{ debugName: "patterns" }] : /* istanbul ignore next */ []));
|
|
1915
|
+
prefix = input('', ...(ngDevMode ? [{ debugName: "prefix" }] : /* istanbul ignore next */ []));
|
|
1916
|
+
suffix = input('', ...(ngDevMode ? [{ debugName: "suffix" }] : /* istanbul ignore next */ []));
|
|
1917
|
+
thousandSeparator = input(' ', ...(ngDevMode ? [{ debugName: "thousandSeparator" }] : /* istanbul ignore next */ []));
|
|
1918
|
+
decimalMarker = input('.', ...(ngDevMode ? [{ debugName: "decimalMarker" }] : /* istanbul ignore next */ []));
|
|
1919
|
+
dropSpecialCharacters = input(null, ...(ngDevMode ? [{ debugName: "dropSpecialCharacters" }] : /* istanbul ignore next */ []));
|
|
1920
|
+
hiddenInput = input(null, ...(ngDevMode ? [{ debugName: "hiddenInput" }] : /* istanbul ignore next */ []));
|
|
1921
|
+
showMaskTyped = input(null, ...(ngDevMode ? [{ debugName: "showMaskTyped" }] : /* istanbul ignore next */ []));
|
|
1922
|
+
placeHolderCharacter = input(null, ...(ngDevMode ? [{ debugName: "placeHolderCharacter" }] : /* istanbul ignore next */ []));
|
|
1923
|
+
shownMaskExpression = input(null, ...(ngDevMode ? [{ debugName: "shownMaskExpression" }] : /* istanbul ignore next */ []));
|
|
1924
|
+
clearIfNotMatch = input(null, ...(ngDevMode ? [{ debugName: "clearIfNotMatch" }] : /* istanbul ignore next */ []));
|
|
1925
|
+
validation = input(null, ...(ngDevMode ? [{ debugName: "validation" }] : /* istanbul ignore next */ []));
|
|
1926
|
+
separatorLimit = input('', ...(ngDevMode ? [{ debugName: "separatorLimit" }] : /* istanbul ignore next */ []));
|
|
1927
|
+
allowNegativeNumbers = input(null, ...(ngDevMode ? [{ debugName: "allowNegativeNumbers" }] : /* istanbul ignore next */ []));
|
|
1928
|
+
leadZeroDateTime = input(null, ...(ngDevMode ? [{ debugName: "leadZeroDateTime" }] : /* istanbul ignore next */ []));
|
|
1929
|
+
leadZero = input(null, ...(ngDevMode ? [{ debugName: "leadZero" }] : /* istanbul ignore next */ []));
|
|
1930
|
+
triggerOnMaskChange = input(null, ...(ngDevMode ? [{ debugName: "triggerOnMaskChange" }] : /* istanbul ignore next */ []));
|
|
1931
|
+
apm = input(null, ...(ngDevMode ? [{ debugName: "apm" }] : /* istanbul ignore next */ []));
|
|
1932
|
+
inputTransformFn = input(null, ...(ngDevMode ? [{ debugName: "inputTransformFn" }] : /* istanbul ignore next */ []));
|
|
1933
|
+
outputTransformFn = input(null, ...(ngDevMode ? [{ debugName: "outputTransformFn" }] : /* istanbul ignore next */ []));
|
|
1934
|
+
keepCharacterPositions = input(null, ...(ngDevMode ? [{ debugName: "keepCharacterPositions" }] : /* istanbul ignore next */ []));
|
|
1935
|
+
instantPrefix = input(null, ...(ngDevMode ? [{ debugName: "instantPrefix" }] : /* istanbul ignore next */ []));
|
|
1936
|
+
value = model('', ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
1937
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1938
|
+
touched = model(false, ...(ngDevMode ? [{ debugName: "touched" }] : /* istanbul ignore next */ []));
|
|
1662
1939
|
maskFilled = output();
|
|
1663
|
-
_maskValue = signal('', ...(ngDevMode ? [{ debugName: "_maskValue" }] : []));
|
|
1664
|
-
_inputValue = signal('', ...(ngDevMode ? [{ debugName: "_inputValue" }] : []));
|
|
1665
|
-
_position = signal(null, ...(ngDevMode ? [{ debugName: "_position" }] : []));
|
|
1666
|
-
_code = signal('', ...(ngDevMode ? [{ debugName: "_code" }] : []));
|
|
1667
|
-
_maskExpressionArray = signal([], ...(ngDevMode ? [{ debugName: "_maskExpressionArray" }] : []));
|
|
1668
|
-
_justPasted = signal(false, ...(ngDevMode ? [{ debugName: "_justPasted" }] : []));
|
|
1669
|
-
_isFocused = signal(false, ...(ngDevMode ? [{ debugName: "_isFocused" }] : []));
|
|
1940
|
+
_maskValue = signal('', ...(ngDevMode ? [{ debugName: "_maskValue" }] : /* istanbul ignore next */ []));
|
|
1941
|
+
_inputValue = signal('', ...(ngDevMode ? [{ debugName: "_inputValue" }] : /* istanbul ignore next */ []));
|
|
1942
|
+
_position = signal(null, ...(ngDevMode ? [{ debugName: "_position" }] : /* istanbul ignore next */ []));
|
|
1943
|
+
_code = signal('', ...(ngDevMode ? [{ debugName: "_code" }] : /* istanbul ignore next */ []));
|
|
1944
|
+
_maskExpressionArray = signal([], ...(ngDevMode ? [{ debugName: "_maskExpressionArray" }] : /* istanbul ignore next */ []));
|
|
1945
|
+
_justPasted = signal(false, ...(ngDevMode ? [{ debugName: "_justPasted" }] : /* istanbul ignore next */ []));
|
|
1946
|
+
_isFocused = signal(false, ...(ngDevMode ? [{ debugName: "_isFocused" }] : /* istanbul ignore next */ []));
|
|
1670
1947
|
/** For IME composition event */
|
|
1671
|
-
_isComposing = signal(false, ...(ngDevMode ? [{ debugName: "_isComposing" }] : []));
|
|
1672
|
-
/**
|
|
1673
|
-
|
|
1948
|
+
_isComposing = signal(false, ...(ngDevMode ? [{ debugName: "_isComposing" }] : /* istanbul ignore next */ []));
|
|
1949
|
+
/**
|
|
1950
|
+
* True once Angular has driven this directive through the `ControlValueAccessor` contract
|
|
1951
|
+
* (`registerOnChange`). NOTE: Signal Forms' `FormField` ALSO takes this path — it prefers a
|
|
1952
|
+
* host-provided `NG_VALUE_ACCESSOR` over the custom-control `value` model binding (see
|
|
1953
|
+
* `FormField.ɵngControlCreate`), so it calls `registerOnChange`/`writeValue` too and this
|
|
1954
|
+
* flag is `true` in both modes. That is fine: with the flag set, the `value`-model effect
|
|
1955
|
+
* below is a no-op and all rendering goes through `writeValue()`. The `value` model is only
|
|
1956
|
+
* driven directly (flag stays `false`) when the directive is used standalone with a
|
|
1957
|
+
* `[(value)]` binding and no forms integration.
|
|
1958
|
+
*/
|
|
1959
|
+
_isCvaMode = signal(false, ...(ngDevMode ? [{ debugName: "_isCvaMode" }] : /* istanbul ignore next */ []));
|
|
1960
|
+
/** Guards against the value effect echoing back a value we just propagated ourselves. */
|
|
1961
|
+
_skipNextValueEffect = signal(false, ...(ngDevMode ? [{ debugName: "_skipNextValueEffect" }] : /* istanbul ignore next */ []));
|
|
1962
|
+
/**
|
|
1963
|
+
* The exact stringified value last pushed through `onChange` (view → model). Signal Forms'
|
|
1964
|
+
* `FormField` echoes every model update back through `writeValue()` — including updates that
|
|
1965
|
+
* originated from the view. Re-masking that unmasked echo is at best a redundant re-render
|
|
1966
|
+
* and at worst destructive for masks whose unmasked form is ambiguous (e.g. IP:
|
|
1967
|
+
* '192168178' cannot reconstruct the typed dots of '192.168.1.78'). `writeValue()` consumes
|
|
1968
|
+
* this marker to skip exactly that echo. `null` = no pending propagation.
|
|
1969
|
+
*/
|
|
1970
|
+
_lastPropagatedValue = null;
|
|
1971
|
+
/**
|
|
1972
|
+
* True once the first `ngOnChanges` pass has applied the mask configuration to the service.
|
|
1973
|
+
* Signal Forms' `FormField` syncs its field value through the template `ɵɵcontrol` update
|
|
1974
|
+
* instruction, which runs BEFORE the sibling directives' first `ngOnChanges` on the same
|
|
1975
|
+
* element — so the very first `writeValue()` would otherwise see an unconfigured service
|
|
1976
|
+
* (empty `maskExpression`, default `leadZero`/`thousandSeparator`/...) and render the raw
|
|
1977
|
+
* value. Until this flag is set, `writeValue()` stashes the incoming value and `ngOnChanges`
|
|
1978
|
+
* replays it once the configuration is in place.
|
|
1979
|
+
*/
|
|
1980
|
+
_configApplied = false;
|
|
1981
|
+
_pendingInitialValue;
|
|
1982
|
+
_hasPendingInitialValue = false;
|
|
1983
|
+
/**
|
|
1984
|
+
* True once the `disabled` input has ever delivered `true`. The disabled effect's very
|
|
1985
|
+
* first run fires with the input's default `false` even when nothing binds `[disabled]`.
|
|
1986
|
+
* Because effects run after Angular Forms' `setUpControl` (which calls
|
|
1987
|
+
* `setDisabledState(true)` for initially-disabled controls) and both DOM writes are
|
|
1988
|
+
* queueMicrotask-deferred in FIFO order, forwarding that default `false` would land last
|
|
1989
|
+
* and re-enable an initially-disabled control (#1607, #1614). An initial `false` write is
|
|
1990
|
+
* never needed — inputs are enabled by default — so `false` is only forwarded after the
|
|
1991
|
+
* input has explicitly driven the state to `true` at least once.
|
|
1992
|
+
*/
|
|
1993
|
+
_disabledEverSet = false;
|
|
1994
|
+
/** Ensures the multi-character placeHolderCharacter warning (#1347) is emitted only once. */
|
|
1995
|
+
_warnedAboutMultiCharPlaceholder = false;
|
|
1674
1996
|
_maskService = inject(NgxMaskService, { self: true });
|
|
1675
1997
|
document = inject(DOCUMENT);
|
|
1676
1998
|
_config = inject(NGX_MASK_CONFIG);
|
|
1999
|
+
/**
|
|
2000
|
+
* Under zoneless change detection, writing to the underlying FormControl programmatically
|
|
2001
|
+
* (e.g. `setValue`/`patchValue` from outside a signal/effect context, or from a callback
|
|
2002
|
+
* zone.js used to auto-flush like `requestAnimationFrame`/`queueMicrotask`) does not itself
|
|
2003
|
+
* trigger a CD run. Bindings that read `form.pristine`/`form.dirty`/`form.value` on the host
|
|
2004
|
+
* template need an explicit `markForCheck()` once the directive finishes reacting to a
|
|
2005
|
+
* programmatic value write, otherwise the view stays stale until something else schedules CD.
|
|
2006
|
+
*/
|
|
2007
|
+
_changeDetectorRef = inject(ChangeDetectorRef);
|
|
2008
|
+
_elementRef = inject(ElementRef);
|
|
2009
|
+
_renderer = inject(Renderer2);
|
|
2010
|
+
// Injector is used to lazily resolve NgControl. NgControl cannot be injected directly:
|
|
2011
|
+
// this directive is registered as the control's NG_VALUE_ACCESSOR, so a direct
|
|
2012
|
+
// inject(NgControl) would form a circular dependency. Resolving it lazily on first use
|
|
2013
|
+
// (after the control graph is wired) sidesteps the cycle.
|
|
2014
|
+
_injector = inject(Injector);
|
|
2015
|
+
_ngControl;
|
|
2016
|
+
_resolveNgControl() {
|
|
2017
|
+
if (typeof this._ngControl === 'undefined') {
|
|
2018
|
+
this._ngControl = this._injector.get(NgControl, null);
|
|
2019
|
+
}
|
|
2020
|
+
return this._ngControl;
|
|
2021
|
+
}
|
|
1677
2022
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
1678
2023
|
onChange = (_) => { };
|
|
1679
2024
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
1680
2025
|
onTouch = () => { };
|
|
1681
2026
|
constructor() {
|
|
2027
|
+
// Default onChange used in Signal Forms mode, where Angular never calls registerOnChange.
|
|
2028
|
+
// It pushes the unmasked value into the `value` model so the `valueChange` output fires.
|
|
2029
|
+
// registerOnChange() overrides this to additionally invoke Angular's CVA callback.
|
|
2030
|
+
this._maskService.onChange = this.onChange = (value) => {
|
|
2031
|
+
this._propagateToValueModel(value);
|
|
2032
|
+
};
|
|
2033
|
+
// Signal Forms drives the `value` model input directly (custom-control mode).
|
|
2034
|
+
// In classic CVA mode `writeValue` handles rendering instead, so this effect is a no-op
|
|
2035
|
+
// there. We skip the pass immediately following our own onChange propagation to avoid
|
|
2036
|
+
// clobbering the raw `_inputValue` with the masked value echoed back from the model.
|
|
1682
2037
|
effect(() => {
|
|
1683
2038
|
const signalValue = this.value();
|
|
1684
|
-
|
|
1685
|
-
|
|
2039
|
+
untracked(() => {
|
|
2040
|
+
if (this._isCvaMode()) {
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
if (this._skipNextValueEffect()) {
|
|
2044
|
+
this._skipNextValueEffect.set(false);
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
if (String(signalValue) !== String(this._inputValue())) {
|
|
1686
2048
|
this.writeValue(signalValue);
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
1689
2051
|
});
|
|
1690
2052
|
effect(() => {
|
|
1691
2053
|
const isDisabled = this.disabled();
|
|
1692
2054
|
untracked(() => {
|
|
2055
|
+
// Never force-enable on the default `false`: it would clobber Angular Forms'
|
|
2056
|
+
// own setDisabledState(true) for initially-disabled controls (see
|
|
2057
|
+
// _disabledEverSet). `false` is forwarded only after an explicit `true`.
|
|
2058
|
+
if (!isDisabled && !this._disabledEverSet) {
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
this._disabledEverSet = true;
|
|
1693
2062
|
this.setDisabledState(isDisabled);
|
|
1694
2063
|
});
|
|
1695
2064
|
});
|
|
1696
2065
|
}
|
|
1697
|
-
ngOnInit() {
|
|
1698
|
-
// Detect if we're being used with Signal Forms
|
|
1699
|
-
// Signal Forms will set the value model from outside
|
|
1700
|
-
if (this.value() !== '') {
|
|
1701
|
-
this._isSignalFormsMode.set(true);
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
2066
|
ngOnChanges(changes) {
|
|
1705
2067
|
const { mask, specialCharacters, patterns, prefix, suffix, thousandSeparator, decimalMarker, dropSpecialCharacters, hiddenInput, showMaskTyped, placeHolderCharacter, shownMaskExpression, clearIfNotMatch, validation, separatorLimit, allowNegativeNumbers, leadZeroDateTime, leadZero, triggerOnMaskChange, apm, inputTransformFn, outputTransformFn, keepCharacterPositions, instantPrefix, } = changes;
|
|
1706
2068
|
if (mask) {
|
|
@@ -1720,12 +2082,12 @@ class NgxMaskDirective {
|
|
|
1720
2082
|
}
|
|
1721
2083
|
}
|
|
1722
2084
|
if (specialCharacters) {
|
|
1723
|
-
if (
|
|
1724
|
-
|
|
1725
|
-
}
|
|
1726
|
-
else {
|
|
1727
|
-
this._maskService.specialCharacters = specialCharacters.currentValue || [];
|
|
2085
|
+
if (Array.isArray(specialCharacters.currentValue)) {
|
|
2086
|
+
this._maskService.specialCharacters = specialCharacters.currentValue;
|
|
1728
2087
|
}
|
|
2088
|
+
// A non-array value (e.g. null from an unset dynamic config) keeps the current
|
|
2089
|
+
// service value (config defaults) and must not abort the whole ngOnChanges pass,
|
|
2090
|
+
// otherwise sibling inputs changed in the same cycle would be silently ignored (#1512).
|
|
1729
2091
|
}
|
|
1730
2092
|
if (allowNegativeNumbers) {
|
|
1731
2093
|
this._maskService.allowNegativeNumbers = allowNegativeNumbers.currentValue;
|
|
@@ -1797,6 +2159,13 @@ class NgxMaskDirective {
|
|
|
1797
2159
|
}
|
|
1798
2160
|
if (placeHolderCharacter) {
|
|
1799
2161
|
this._maskService.placeHolderCharacter = placeHolderCharacter.currentValue;
|
|
2162
|
+
if (typeof placeHolderCharacter.currentValue === 'string' &&
|
|
2163
|
+
placeHolderCharacter.currentValue.length > 1 &&
|
|
2164
|
+
!this._warnedAboutMultiCharPlaceholder) {
|
|
2165
|
+
this._warnedAboutMultiCharPlaceholder = true;
|
|
2166
|
+
// eslint-disable-next-line no-console
|
|
2167
|
+
console.warn('Ngx-mask: placeHolderCharacter should be a single character; behavior with multi-character values is undefined (e.g. keepCharacterPositions will not work). Current value:', placeHolderCharacter.currentValue);
|
|
2168
|
+
}
|
|
1800
2169
|
}
|
|
1801
2170
|
if (shownMaskExpression) {
|
|
1802
2171
|
this._maskService.shownMaskExpression = shownMaskExpression.currentValue;
|
|
@@ -1829,6 +2198,17 @@ class NgxMaskDirective {
|
|
|
1829
2198
|
this._maskService.keepCharacterPositions = keepCharacterPositions.currentValue;
|
|
1830
2199
|
}
|
|
1831
2200
|
this._applyMask();
|
|
2201
|
+
if (!this._configApplied) {
|
|
2202
|
+
this._configApplied = true;
|
|
2203
|
+
if (this._hasPendingInitialValue) {
|
|
2204
|
+
// Replay the writeValue() call that arrived before this first ngOnChanges pass
|
|
2205
|
+
// (see _configApplied) now that the mask configuration is applied.
|
|
2206
|
+
this._hasPendingInitialValue = false;
|
|
2207
|
+
const pendingValue = this._pendingInitialValue;
|
|
2208
|
+
this._pendingInitialValue = null;
|
|
2209
|
+
void this.writeValue(pendingValue);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
1832
2212
|
}
|
|
1833
2213
|
validate({ value }) {
|
|
1834
2214
|
const processedValue = typeof value === 'number' ? String(value) : value;
|
|
@@ -1887,9 +2267,31 @@ class NgxMaskDirective {
|
|
|
1887
2267
|
else if (maskValue.indexOf(key) !== -1) {
|
|
1888
2268
|
counterOfOpt++;
|
|
1889
2269
|
}
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
2270
|
+
const firstOptionalIndex = maskValue.indexOf(key);
|
|
2271
|
+
if (firstOptionalIndex !== -1 && processedValue.length >= firstOptionalIndex) {
|
|
2272
|
+
// #1515: the early "everything before the first optional token is
|
|
2273
|
+
// filled" return is only sound for trailing-optional layouts. When a
|
|
2274
|
+
// mandatory token follows the first optional one (e.g. `999SSS`,
|
|
2275
|
+
// indexOf === 0) it used to mark EVERY value as valid.
|
|
2276
|
+
const hasMandatoryAfterOptional = maskValue
|
|
2277
|
+
.slice(firstOptionalIndex)
|
|
2278
|
+
.split(MaskExpression.EMPTY_STRING)
|
|
2279
|
+
.some((symbol) => !!this._maskService.patterns[symbol] &&
|
|
2280
|
+
!this._maskService.patterns[symbol]?.optional);
|
|
2281
|
+
if (!hasMandatoryAfterOptional) {
|
|
2282
|
+
return null;
|
|
2283
|
+
}
|
|
2284
|
+
if (!this.prefix() && !this.suffix() && this._isPlainTokenMask(maskValue)) {
|
|
2285
|
+
// Position-aware check: match the value against the mask allowing
|
|
2286
|
+
// optional tokens to be skipped; every mandatory token must be
|
|
2287
|
+
// consumed (catches `12a` for `999SSS`, which a pure length
|
|
2288
|
+
// check cannot).
|
|
2289
|
+
return this._matchesMaskWithOptionalSkip(processedValue, maskValue)
|
|
2290
|
+
? null
|
|
2291
|
+
: this._createValidationError(processedValue);
|
|
2292
|
+
}
|
|
2293
|
+
// Non-plain masks (prefix/suffix, exotic symbols) fall through to the
|
|
2294
|
+
// legacy length check below (`maskValue.length - counterOfOpt`).
|
|
1893
2295
|
}
|
|
1894
2296
|
if (counterOfOpt === maskValue.length) {
|
|
1895
2297
|
return null;
|
|
@@ -1914,6 +2316,13 @@ class NgxMaskDirective {
|
|
|
1914
2316
|
: maskValue.length - counterOfOpt;
|
|
1915
2317
|
if (array.length === 1) {
|
|
1916
2318
|
if (processedValue.length < length) {
|
|
2319
|
+
// #1583: for `||` multi-masks, a value shorter than the selected
|
|
2320
|
+
// alternative can still be complete: it must stop exactly at a
|
|
2321
|
+
// special-character boundary of the selected alternative and satisfy
|
|
2322
|
+
// the length requirement of another (shorter) alternative.
|
|
2323
|
+
if (this._isCompleteAlternativeBoundary(processedValue)) {
|
|
2324
|
+
return null;
|
|
2325
|
+
}
|
|
1917
2326
|
return this._createValidationError(processedValue);
|
|
1918
2327
|
}
|
|
1919
2328
|
}
|
|
@@ -1956,6 +2365,7 @@ class NgxMaskDirective {
|
|
|
1956
2365
|
}
|
|
1957
2366
|
onFocus() {
|
|
1958
2367
|
this._isFocused.set(true);
|
|
2368
|
+
this._maskService._isFocused.set(true);
|
|
1959
2369
|
}
|
|
1960
2370
|
onModelChange(value) {
|
|
1961
2371
|
// on form reset we need to update the actualValue
|
|
@@ -1968,8 +2378,30 @@ class NgxMaskDirective {
|
|
|
1968
2378
|
}
|
|
1969
2379
|
onInput(e) {
|
|
1970
2380
|
this._maskService.isInitialized = true;
|
|
1971
|
-
//
|
|
1972
|
-
|
|
2381
|
+
// Android IMEs report every keydown as key 'Unidentified' / keyCode 229, so a
|
|
2382
|
+
// backspace is undetectable from keydown alone and all _code()-based deletion
|
|
2383
|
+
// branches misfire (cursor jumps, "stuck" backspace — #1497). The input event's
|
|
2384
|
+
// inputType is the reliable source: derive the deletion code from it.
|
|
2385
|
+
const inputType = e.inputType;
|
|
2386
|
+
if (inputType === 'deleteContentBackward') {
|
|
2387
|
+
this._code.set(MaskExpression.BACKSPACE);
|
|
2388
|
+
}
|
|
2389
|
+
else if (inputType === 'deleteContentForward') {
|
|
2390
|
+
this._code.set(MaskExpression.DELETE);
|
|
2391
|
+
}
|
|
2392
|
+
else if (inputType &&
|
|
2393
|
+
(this._code() === MaskExpression.BACKSPACE || this._code() === MaskExpression.DELETE)) {
|
|
2394
|
+
// A non-delete edit that produced no meaningful keydown (IME insertion,
|
|
2395
|
+
// context-menu paste): clear the stale deletion code so the edit is not
|
|
2396
|
+
// processed as a backspace.
|
|
2397
|
+
this._code.set(inputType);
|
|
2398
|
+
}
|
|
2399
|
+
// If IME is composing text, we wait for the composed text — but only when the mask
|
|
2400
|
+
// can actually accept letters. Purely numeric masks gain nothing from composition,
|
|
2401
|
+
// and some Android IMEs (Samsung Keyboard) deliver every keystroke as
|
|
2402
|
+
// insertCompositionText without firing compositionend until blur, which would leave
|
|
2403
|
+
// the FormControl stale for the whole edit (#1293).
|
|
2404
|
+
if (this._isComposing() && this._maskAcceptsLetterInput()) {
|
|
1973
2405
|
return;
|
|
1974
2406
|
}
|
|
1975
2407
|
const el = e.target;
|
|
@@ -1978,51 +2410,90 @@ class NgxMaskDirective {
|
|
|
1978
2410
|
: el.value;
|
|
1979
2411
|
if (el.type !== 'number') {
|
|
1980
2412
|
if (typeof transformedValue === 'string' || typeof transformedValue === 'number') {
|
|
1981
|
-
|
|
2413
|
+
const transformedString = transformedValue.toString();
|
|
2414
|
+
// Only rewrite el.value when the transform actually changed it: any
|
|
2415
|
+
// programmatic `.value =` assignment clears the browser's "last changed
|
|
2416
|
+
// by a user edit" flag, which silently disables native constraint
|
|
2417
|
+
// validation (minlength -> validity.tooShort) even when the mask is a
|
|
2418
|
+
// no-op (#1379). With an empty mask the directive must stay a pure
|
|
2419
|
+
// passthrough for the native input.
|
|
2420
|
+
if (el.value !== transformedString) {
|
|
2421
|
+
el.value = transformedString;
|
|
2422
|
+
}
|
|
1982
2423
|
this._inputValue.set(el.value);
|
|
1983
2424
|
this._setMask();
|
|
1984
2425
|
if (!this._maskValue()) {
|
|
1985
2426
|
this.onChange(el.value);
|
|
1986
2427
|
return;
|
|
1987
2428
|
}
|
|
2429
|
+
// On paste of a raw value that does not yet carry the prefix, the caret
|
|
2430
|
+
// position reported by the element is prefix.length short of where it must
|
|
2431
|
+
// land once applyMask prepends the prefix (#1571). Captured here because the
|
|
2432
|
+
// applyValueChanges callback below resets the _justPasted flag.
|
|
2433
|
+
const pastedValueWithoutPrefix = this._justPasted() &&
|
|
2434
|
+
!!this._maskService.prefix &&
|
|
2435
|
+
!el.value.startsWith(this._maskService.prefix);
|
|
1988
2436
|
let position = el.selectionStart === 1
|
|
1989
2437
|
? el.selectionStart + this._maskService.prefix.length
|
|
1990
2438
|
: el.selectionStart;
|
|
1991
|
-
if (this.
|
|
1992
|
-
this.
|
|
1993
|
-
this.
|
|
2439
|
+
if (this.keepCharacterPositions() &&
|
|
2440
|
+
this._maskService.placeHolderCharacter.length === 1 &&
|
|
2441
|
+
!this._justPasted()) {
|
|
1994
2442
|
const suffix = this.suffix();
|
|
1995
2443
|
const prefix = this.prefix();
|
|
1996
2444
|
const inputSymbol = el.value.slice(position - 1, position);
|
|
1997
2445
|
const prefixLength = prefix.length;
|
|
1998
|
-
const
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
const
|
|
2446
|
+
const showMaskTyped = this.showMaskTyped();
|
|
2447
|
+
const maskExpression = this._maskService.maskExpression;
|
|
2448
|
+
// Placeholder skeleton of the mask (special chars kept, fillable slots
|
|
2449
|
+
// replaced by the placeholder character). With showMaskTyped the service
|
|
2450
|
+
// renders it as maskIsShown; without showMaskTyped it is derived locally so
|
|
2451
|
+
// keepCharacterPositions works on its own (#1545, #1543).
|
|
2452
|
+
const maskSkeleton = this._maskService.maskIsShown.length
|
|
2453
|
+
? this._maskService.maskIsShown
|
|
2454
|
+
: maskExpression.replace(/\w/g, this._maskService.placeHolderCharacter);
|
|
2455
|
+
const hasSelection = this._maskService.selStart !== this._maskService.selEnd;
|
|
2456
|
+
const selStartAbs = Number(this._maskService.selStart);
|
|
2457
|
+
const selEndAbs = Number(this._maskService.selEnd);
|
|
2458
|
+
const selStart = selStartAbs - prefixLength;
|
|
2459
|
+
const selEnd = selEndAbs - prefixLength;
|
|
2005
2460
|
const backspaceOrDelete = this._code() === MaskExpression.BACKSPACE ||
|
|
2006
2461
|
this._code() === MaskExpression.DELETE;
|
|
2462
|
+
// Whether this block fully resolved the resulting display value into
|
|
2463
|
+
// this._maskService.actualValue. When true, applyMask short-circuits and
|
|
2464
|
+
// renders actualValue as-is; when false the edit flows through regular
|
|
2465
|
+
// masking (e.g. appending at the end, or clearing the whole value).
|
|
2466
|
+
let kcpHandled = true;
|
|
2007
2467
|
if (backspaceOrDelete) {
|
|
2008
|
-
if (
|
|
2009
|
-
|
|
2010
|
-
|
|
2468
|
+
if (hasSelection) {
|
|
2469
|
+
const preEditLength = el.value.length + (selEndAbs - selStartAbs);
|
|
2470
|
+
if (!showMaskTyped &&
|
|
2471
|
+
selStartAbs <= prefixLength &&
|
|
2472
|
+
selEndAbs >= preEditLength) {
|
|
2473
|
+
// The whole value was selected and deleted: clear through the
|
|
2474
|
+
// regular path instead of showing a placeholder skeleton.
|
|
2475
|
+
kcpHandled = false;
|
|
2476
|
+
}
|
|
2477
|
+
else if (this._maskService.selStart === prefixLength) {
|
|
2478
|
+
this._maskService.actualValue = `${prefix}${maskSkeleton.slice(0, selEnd)}${this._inputValue().split(prefix).join('')}`;
|
|
2011
2479
|
}
|
|
2012
2480
|
else if (this._maskService.selStart ===
|
|
2013
|
-
|
|
2014
|
-
this._maskService.actualValue = `${this._inputValue()}${
|
|
2481
|
+
maskSkeleton.length + prefixLength) {
|
|
2482
|
+
this._maskService.actualValue = `${this._inputValue()}${maskSkeleton.slice(selStart, selEnd)}`;
|
|
2015
2483
|
}
|
|
2016
2484
|
else {
|
|
2017
2485
|
this._maskService.actualValue = `${prefix}${this._inputValue()
|
|
2018
2486
|
.split(prefix)
|
|
2019
2487
|
.join('')
|
|
2020
|
-
.slice(0, selStart)}${
|
|
2488
|
+
.slice(0, selStart)}${maskSkeleton.slice(selStart, selEnd)}${this._maskService.actualValue.slice(selEnd + prefixLength, maskSkeleton.length + prefixLength)}${suffix}`;
|
|
2021
2489
|
}
|
|
2022
2490
|
}
|
|
2023
|
-
else if (!this._maskService.specialCharacters.includes(
|
|
2024
|
-
|
|
2025
|
-
|
|
2491
|
+
else if (!this._maskService.specialCharacters.includes(maskExpression.slice(position - prefixLength, position + 1 - prefixLength))) {
|
|
2492
|
+
if (!showMaskTyped && position >= el.value.length) {
|
|
2493
|
+
// Deleting the last character: no gap needs to be kept.
|
|
2494
|
+
kcpHandled = false;
|
|
2495
|
+
}
|
|
2496
|
+
else if (selStart === 1 && prefix) {
|
|
2026
2497
|
this._maskService.actualValue = `${prefix}${this._maskService.placeHolderCharacter}${el.value
|
|
2027
2498
|
.split(prefix)
|
|
2028
2499
|
.join('')
|
|
@@ -2038,35 +2509,90 @@ class NgxMaskDirective {
|
|
|
2038
2509
|
}
|
|
2039
2510
|
position = this._code() === MaskExpression.DELETE ? position + 1 : position;
|
|
2040
2511
|
}
|
|
2041
|
-
if (
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2512
|
+
else if (hasSelection) {
|
|
2513
|
+
// A selected range was replaced by the typed symbol. Keep the layout:
|
|
2514
|
+
// blank the selection to the mask skeleton and put the typed symbol
|
|
2515
|
+
// into the first fillable slot of the selection (#1527, #1489).
|
|
2516
|
+
const preEditLength = el.value.length - 1 + (selEndAbs - selStartAbs);
|
|
2517
|
+
if (!showMaskTyped &&
|
|
2518
|
+
selStartAbs <= prefixLength &&
|
|
2519
|
+
selEndAbs >= preEditLength) {
|
|
2520
|
+
// The whole value was replaced: mask it through the regular path.
|
|
2521
|
+
kcpHandled = false;
|
|
2050
2522
|
}
|
|
2051
|
-
else
|
|
2052
|
-
|
|
2053
|
-
|
|
2523
|
+
else {
|
|
2524
|
+
let maskIdx = Math.max(selStart, 0);
|
|
2525
|
+
while (maskIdx < maskExpression.length &&
|
|
2526
|
+
this._maskService.specialCharacters.includes(maskExpression[maskIdx] ?? MaskExpression.EMPTY_STRING)) {
|
|
2527
|
+
maskIdx += 1;
|
|
2528
|
+
}
|
|
2529
|
+
const blanked = `${el.value.slice(0, selStartAbs)}${maskSkeleton.slice(Math.max(selStart, 0), selEnd)}${el.value.slice(selStartAbs + 1)}`;
|
|
2530
|
+
const targetAbs = maskIdx + prefixLength;
|
|
2531
|
+
if (targetAbs < blanked.length - suffix.length &&
|
|
2532
|
+
this._maskService._checkSymbolMask(inputSymbol, maskExpression[maskIdx] ?? MaskExpression.EMPTY_STRING)) {
|
|
2533
|
+
this._maskService.actualValue = `${blanked.slice(0, targetAbs)}${inputSymbol}${blanked.slice(targetAbs + 1)}`;
|
|
2534
|
+
position = targetAbs + 1;
|
|
2054
2535
|
}
|
|
2055
2536
|
else {
|
|
2056
|
-
this._maskService.actualValue =
|
|
2057
|
-
|
|
2058
|
-
.split(suffix)
|
|
2059
|
-
.join('')}${suffix}`;
|
|
2537
|
+
this._maskService.actualValue = blanked;
|
|
2538
|
+
position = selStartAbs;
|
|
2060
2539
|
}
|
|
2061
2540
|
}
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2541
|
+
}
|
|
2542
|
+
else {
|
|
2543
|
+
// Single-caret insert (overwrite mode): the typed symbol lands in the
|
|
2544
|
+
// first fillable mask slot at or after the caret, skipping any number
|
|
2545
|
+
// of special characters (#1544, #1489).
|
|
2546
|
+
const oldDisplay = `${el.value.slice(0, position - 1)}${el.value.slice(position)}`;
|
|
2547
|
+
const oldDisplayNoSuffix = suffix
|
|
2548
|
+
? oldDisplay.split(suffix).join('')
|
|
2549
|
+
: oldDisplay;
|
|
2550
|
+
let maskIdx = position - 1 - prefixLength;
|
|
2551
|
+
if (maskIdx < 0) {
|
|
2552
|
+
// Typed inside the prefix: reject the symbol.
|
|
2553
|
+
position = position - 1;
|
|
2554
|
+
}
|
|
2555
|
+
else {
|
|
2556
|
+
while (maskIdx < maskExpression.length &&
|
|
2557
|
+
this._maskService.specialCharacters.includes(maskExpression[maskIdx] ?? MaskExpression.EMPTY_STRING)) {
|
|
2558
|
+
maskIdx += 1;
|
|
2559
|
+
}
|
|
2560
|
+
const targetAbs = maskIdx + prefixLength;
|
|
2561
|
+
if (targetAbs >= oldDisplayNoSuffix.length) {
|
|
2562
|
+
if (oldDisplayNoSuffix.length <= prefixLength || !showMaskTyped) {
|
|
2563
|
+
// First symbol, or appending at the end without
|
|
2564
|
+
// showMaskTyped: regular masking handles it (including
|
|
2565
|
+
// leadZeroDateTime insertions).
|
|
2566
|
+
kcpHandled = false;
|
|
2567
|
+
}
|
|
2568
|
+
else {
|
|
2569
|
+
// All slots of the rendered mask are already consumed.
|
|
2570
|
+
position = position - 1;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
else if (this._maskService._checkSymbolMask(inputSymbol, maskExpression[maskIdx] ?? MaskExpression.EMPTY_STRING)) {
|
|
2574
|
+
this._maskService.actualValue = `${oldDisplayNoSuffix.slice(0, targetAbs)}${inputSymbol}${oldDisplayNoSuffix.slice(targetAbs + 1)}${suffix}`;
|
|
2575
|
+
position = targetAbs + 1;
|
|
2576
|
+
}
|
|
2577
|
+
else {
|
|
2578
|
+
// Rejected symbol: keep the current display.
|
|
2579
|
+
position = position - 1;
|
|
2580
|
+
}
|
|
2068
2581
|
}
|
|
2069
2582
|
}
|
|
2583
|
+
this._maskService.keepCharacterPositionsHandled = kcpHandled;
|
|
2584
|
+
}
|
|
2585
|
+
// A literal '*' typed into the VALUE (custom pattern allowing asterisks) makes
|
|
2586
|
+
// the service's applyMask take its hiddenInput shadow-value branch, whose
|
|
2587
|
+
// equal/shorter-length cases restore the stale pre-edit actualValue and discard
|
|
2588
|
+
// a selection replacement (#1504). Without hiddenInput there is no shadow state
|
|
2589
|
+
// to preserve, so drop it and let the edited value flow through regular masking.
|
|
2590
|
+
if (!this._maskService.hiddenInput &&
|
|
2591
|
+
!this._maskService.showMaskTyped &&
|
|
2592
|
+
!this.keepCharacterPositions() &&
|
|
2593
|
+
this._maskService.selStart !== this._maskService.selEnd &&
|
|
2594
|
+
el.value.includes(MaskExpression.SYMBOL_STAR)) {
|
|
2595
|
+
this._maskService.actualValue = MaskExpression.EMPTY_STRING;
|
|
2070
2596
|
}
|
|
2071
2597
|
let caretShift = 0;
|
|
2072
2598
|
let backspaceShift = false;
|
|
@@ -2144,11 +2670,30 @@ class NgxMaskDirective {
|
|
|
2144
2670
|
(this._code() === MaskExpression.BACKSPACE && !backspaceShift
|
|
2145
2671
|
? 0
|
|
2146
2672
|
: caretShift);
|
|
2673
|
+
// For separator masks the applier's caret shift is computed on the raw
|
|
2674
|
+
// (prefix-less) value, so account for the prefix the mask just added (#1571).
|
|
2675
|
+
if (pastedValueWithoutPrefix &&
|
|
2676
|
+
this._maskValue().startsWith(MaskExpression.SEPARATOR)) {
|
|
2677
|
+
positionToApply += this._maskService.prefix.length;
|
|
2678
|
+
}
|
|
2147
2679
|
if (positionToApply > this._getActualInputLength()) {
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2680
|
+
// A bare decimal marker ('.' or, with a prefix, '$.') is not a number:
|
|
2681
|
+
// formControlResult has just emitted null for it, which the ngModelChange
|
|
2682
|
+
// reset handler treats as a form reset and clears actualValue. The plain
|
|
2683
|
+
// length clamp would then land the caret BEFORE the marker — keep it right
|
|
2684
|
+
// after the marker instead (#1572).
|
|
2685
|
+
const decimalMarker = this._maskService.decimalMarker;
|
|
2686
|
+
const prefix = this._maskService.prefix;
|
|
2687
|
+
const valueWithoutPrefix = el.value.startsWith(prefix)
|
|
2688
|
+
? el.value.slice(prefix.length)
|
|
2689
|
+
: el.value;
|
|
2690
|
+
const isBareDecimalMarker = valueWithoutPrefix.length === 1 &&
|
|
2691
|
+
(Array.isArray(decimalMarker)
|
|
2692
|
+
? decimalMarker.includes(valueWithoutPrefix)
|
|
2693
|
+
: valueWithoutPrefix === decimalMarker);
|
|
2694
|
+
positionToApply = isBareDecimalMarker
|
|
2695
|
+
? this._getActualInputLength() + 1
|
|
2696
|
+
: this._getActualInputLength();
|
|
2152
2697
|
}
|
|
2153
2698
|
if (positionToApply < 0) {
|
|
2154
2699
|
positionToApply = 0;
|
|
@@ -2169,6 +2714,29 @@ class NgxMaskDirective {
|
|
|
2169
2714
|
this._maskService.applyValueChanges(el.value.length, this._justPasted(), this._code() === MaskExpression.BACKSPACE || this._code() === MaskExpression.DELETE);
|
|
2170
2715
|
}
|
|
2171
2716
|
}
|
|
2717
|
+
/**
|
|
2718
|
+
* Whether any pattern slot of the current mask expression can accept a letter.
|
|
2719
|
+
* IME composition is only meaningful for such masks; for purely numeric masks
|
|
2720
|
+
* (digit patterns, separator, date/time, IP, CPF_CNPJ) waiting for compositionend
|
|
2721
|
+
* only delays the model sync — and Samsung Keyboard may never fire it until blur
|
|
2722
|
+
* (#1293). Unknown/letterless probe failures fall back to `false` (process live).
|
|
2723
|
+
*/
|
|
2724
|
+
_maskAcceptsLetterInput() {
|
|
2725
|
+
const patterns = this._maskService.patterns;
|
|
2726
|
+
const maskExpression = this._maskService.maskExpression;
|
|
2727
|
+
for (const symbol of maskExpression) {
|
|
2728
|
+
const pattern = patterns[symbol]?.pattern;
|
|
2729
|
+
if (!pattern) {
|
|
2730
|
+
continue;
|
|
2731
|
+
}
|
|
2732
|
+
// Re-create without sticky/global flags: test() on those is stateful.
|
|
2733
|
+
const probe = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
|
|
2734
|
+
if (probe.test('a') || probe.test('A')) {
|
|
2735
|
+
return true;
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
return false;
|
|
2739
|
+
}
|
|
2172
2740
|
// IME starts
|
|
2173
2741
|
onCompositionStart() {
|
|
2174
2742
|
this._isComposing.set(true);
|
|
@@ -2176,6 +2744,12 @@ class NgxMaskDirective {
|
|
|
2176
2744
|
// IME completes
|
|
2177
2745
|
onCompositionEnd(e) {
|
|
2178
2746
|
this._isComposing.set(false);
|
|
2747
|
+
if (!this._maskAcceptsLetterInput()) {
|
|
2748
|
+
// For letterless masks every edit was already processed live in onInput
|
|
2749
|
+
// (#1293); reprocessing the same value through the paste path would
|
|
2750
|
+
// double-apply the mask.
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2179
2753
|
this._justPasted.set(true);
|
|
2180
2754
|
this.onInput(e);
|
|
2181
2755
|
}
|
|
@@ -2194,18 +2768,20 @@ class NgxMaskDirective {
|
|
|
2194
2768
|
const decimalPart = el.value.split(decimalMarker)[1];
|
|
2195
2769
|
el.value = el.value.includes(decimalMarker)
|
|
2196
2770
|
? el.value +
|
|
2197
|
-
MaskExpression.NUMBER_ZERO.repeat(precision - decimalPart
|
|
2771
|
+
MaskExpression.NUMBER_ZERO.repeat(precision - (decimalPart?.length || 0)) +
|
|
2198
2772
|
suffix
|
|
2199
2773
|
: el.value +
|
|
2200
2774
|
decimalMarker +
|
|
2201
2775
|
MaskExpression.NUMBER_ZERO.repeat(precision) +
|
|
2202
2776
|
suffix;
|
|
2203
2777
|
this._maskService.actualValue = el.value;
|
|
2778
|
+
this.onChange(this._maskService.actualValue);
|
|
2204
2779
|
}
|
|
2205
2780
|
}
|
|
2206
2781
|
this._maskService.clearIfNotMatchFn();
|
|
2207
2782
|
}
|
|
2208
2783
|
this._isFocused.set(false);
|
|
2784
|
+
this._maskService._isFocused.set(false);
|
|
2209
2785
|
this.onTouch();
|
|
2210
2786
|
}
|
|
2211
2787
|
onClick(e) {
|
|
@@ -2269,7 +2845,15 @@ class NgxMaskDirective {
|
|
|
2269
2845
|
// User finalize their choice from IME composition, so trigger onInput() for the composed text.
|
|
2270
2846
|
if (e.key === 'Enter') {
|
|
2271
2847
|
this.onCompositionEnd(event);
|
|
2848
|
+
return;
|
|
2272
2849
|
}
|
|
2850
|
+
// Android IMEs can keep a single composition open across many keystrokes
|
|
2851
|
+
// (#1293): still capture the pre-edit value and selection that onInput
|
|
2852
|
+
// relies on when it processes edits during composition.
|
|
2853
|
+
const composingEl = e.target;
|
|
2854
|
+
this._inputValue.set(composingEl.value);
|
|
2855
|
+
this._maskService.selStart = composingEl.selectionStart;
|
|
2856
|
+
this._maskService.selEnd = composingEl.selectionEnd;
|
|
2273
2857
|
return;
|
|
2274
2858
|
}
|
|
2275
2859
|
this._code.set(e.code ? e.code : e.key);
|
|
@@ -2289,10 +2873,10 @@ class NgxMaskDirective {
|
|
|
2289
2873
|
}
|
|
2290
2874
|
if (e.key === MaskExpression.BACKSPACE && el.selectionStart !== 0) {
|
|
2291
2875
|
const prefixLength = this.prefix().length;
|
|
2292
|
-
//
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2876
|
+
// Use the service value: it holds the config defaults when the input is not
|
|
2877
|
+
// bound and the bound value otherwise, so an explicitly bound empty array is
|
|
2878
|
+
// respected instead of silently falling back to the defaults (#1512).
|
|
2879
|
+
const specialCharacters = this._maskService.specialCharacters;
|
|
2296
2880
|
if (prefixLength > 1 && el.selectionStart <= prefixLength) {
|
|
2297
2881
|
el.setSelectionRange(prefixLength, el.selectionEnd);
|
|
2298
2882
|
}
|
|
@@ -2321,8 +2905,17 @@ class NgxMaskDirective {
|
|
|
2321
2905
|
cursorStart === 0 &&
|
|
2322
2906
|
el.selectionEnd === el.value.length &&
|
|
2323
2907
|
el.value.length !== 0) {
|
|
2324
|
-
|
|
2325
|
-
|
|
2908
|
+
// Handle the clear fully here instead of emitting the model change and
|
|
2909
|
+
// relying on the browser's default deletion + input event to update the
|
|
2910
|
+
// view: Firefox can drop the default action after the emission below
|
|
2911
|
+
// triggers change detection (DOM churn around the input), leaving the
|
|
2912
|
+
// model empty but the view untouched until a second Backspace (#1350).
|
|
2913
|
+
e.preventDefault();
|
|
2914
|
+
const displayValue = this._maskService.applyMask(MaskExpression.EMPTY_STRING, this._maskService.maskExpression, 0, false, true);
|
|
2915
|
+
el.value = displayValue;
|
|
2916
|
+
this._inputValue.set(displayValue);
|
|
2917
|
+
const caret = Math.min(this._maskService.prefix.length, displayValue.length);
|
|
2918
|
+
el.setSelectionRange(caret, caret);
|
|
2326
2919
|
}
|
|
2327
2920
|
}
|
|
2328
2921
|
if (!!this.suffix() &&
|
|
@@ -2340,8 +2933,53 @@ class NgxMaskDirective {
|
|
|
2340
2933
|
this._maskService.selEnd = el.selectionEnd;
|
|
2341
2934
|
}
|
|
2342
2935
|
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Restores the control's pristine/untouched state after a writeValue-driven emission.
|
|
2938
|
+
*
|
|
2939
|
+
* writeValue is a one-way model->view sync. When the mask normalizes the written value
|
|
2940
|
+
* (e.g. leadZero '10.2' -> '10.20'), the directive must still emit the corrected value so
|
|
2941
|
+
* the model adopts it — but that emission runs through Angular's view-change pipeline, which
|
|
2942
|
+
* calls markAsDirty()/markAsTouched(). A programmatic setValue/patchValue must leave the
|
|
2943
|
+
* control pristine, so we undo that side effect here when the control was pristine before
|
|
2944
|
+
* the write. `onlySelf: true` keeps parent group state untouched.
|
|
2945
|
+
*/
|
|
2946
|
+
_restoreControlStateAfterWrite(wasPristine, wasUntouched) {
|
|
2947
|
+
const ngControl = this._resolveNgControl();
|
|
2948
|
+
const control = ngControl?.control;
|
|
2949
|
+
if (!control) {
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
if (wasPristine && ngControl.dirty && typeof control.markAsPristine === 'function') {
|
|
2953
|
+
control.markAsPristine({ onlySelf: true });
|
|
2954
|
+
}
|
|
2955
|
+
if (wasUntouched && ngControl.touched && typeof control.markAsUntouched === 'function') {
|
|
2956
|
+
control.markAsUntouched({ onlySelf: true });
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2343
2959
|
/** It writes the value in the input */
|
|
2344
2960
|
async writeValue(controlValue) {
|
|
2961
|
+
if (!this._configApplied && this.mask()) {
|
|
2962
|
+
// Called before the first ngOnChanges pass configured the mask service (happens with
|
|
2963
|
+
// Signal Forms' [formField], whose control-sync instruction runs before sibling
|
|
2964
|
+
// directives' ngOnChanges). Defer and replay once the configuration is applied.
|
|
2965
|
+
this._pendingInitialValue = controlValue;
|
|
2966
|
+
this._hasPendingInitialValue = true;
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
// Skip the model echo of a value we just propagated ourselves (see the
|
|
2970
|
+
// _lastPropagatedValue doc). Empty writes are never skipped: form reset('') must
|
|
2971
|
+
// always clear the display and the currentValue/previousValue service state below.
|
|
2972
|
+
const lastPropagated = this._lastPropagatedValue;
|
|
2973
|
+
this._lastPropagatedValue = null;
|
|
2974
|
+
if (lastPropagated !== null &&
|
|
2975
|
+
lastPropagated !== '' &&
|
|
2976
|
+
(typeof controlValue === 'string' || typeof controlValue === 'number') &&
|
|
2977
|
+
String(controlValue) === lastPropagated) {
|
|
2978
|
+
return;
|
|
2979
|
+
}
|
|
2980
|
+
const ngControl = this._resolveNgControl();
|
|
2981
|
+
const wasPristine = ngControl ? Boolean(ngControl.pristine) : true;
|
|
2982
|
+
const wasUntouched = ngControl ? Boolean(ngControl.untouched) : true;
|
|
2345
2983
|
let value = controlValue;
|
|
2346
2984
|
const inputTransformFn = this._maskService.inputTransformFn;
|
|
2347
2985
|
if (typeof value === 'object' && value !== null && 'value' in value) {
|
|
@@ -2389,7 +3027,13 @@ class NgxMaskDirective {
|
|
|
2389
3027
|
const isFirstWrite = !this._maskService.isInitialized;
|
|
2390
3028
|
requestAnimationFrame(() => {
|
|
2391
3029
|
// On initial load, temporarily set isInitialized to false
|
|
2392
|
-
// so formControlResult returns early and doesn't mark form as dirty
|
|
3030
|
+
// so formControlResult returns early and doesn't mark form as dirty.
|
|
3031
|
+
// On later writeValue-driven writes, leave isInitialized as-is: this pass
|
|
3032
|
+
// may legitimately need to push a leadZero-normalized value (e.g.
|
|
3033
|
+
// '10.2' -> '10.20') back to the FormControl, which Angular's forms
|
|
3034
|
+
// pipeline can only do via the same onChange callback used for real user
|
|
3035
|
+
// edits — see the `should change formValue` unit tests, which assert the
|
|
3036
|
+
// normalized value lands in `form.value` after a programmatic setValue.
|
|
2393
3037
|
if (isFirstWrite) {
|
|
2394
3038
|
this._maskService.isInitialized = false;
|
|
2395
3039
|
}
|
|
@@ -2397,6 +3041,13 @@ class NgxMaskDirective {
|
|
|
2397
3041
|
if (isFirstWrite) {
|
|
2398
3042
|
this._maskService.isInitialized = true;
|
|
2399
3043
|
}
|
|
3044
|
+
// The leadZero normalization above emits the corrected value through the
|
|
3045
|
+
// view-change pipeline, which dirties/touches the control. Undo that so a
|
|
3046
|
+
// programmatic write stays pristine.
|
|
3047
|
+
this._restoreControlStateAfterWrite(wasPristine, wasUntouched);
|
|
3048
|
+
// Zoneless CD does not auto-flush after requestAnimationFrame; request
|
|
3049
|
+
// a check so form.pristine/form.value bindings reflect the new state.
|
|
3050
|
+
this._changeDetectorRef.markForCheck();
|
|
2400
3051
|
});
|
|
2401
3052
|
}
|
|
2402
3053
|
this._maskService.isNumberValue = true;
|
|
@@ -2411,48 +3062,87 @@ class NgxMaskDirective {
|
|
|
2411
3062
|
(this._maskService.prefix || this._maskService.showMaskTyped))) {
|
|
2412
3063
|
// Let the service we know we are writing value so that triggering onChange function won't happen during applyMask
|
|
2413
3064
|
this._maskService.writingValue = true;
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
];
|
|
3065
|
+
const displayValue = this._maskedOrVerbatim(inputValue);
|
|
3066
|
+
this._maskService.formElementProperty = ['value', displayValue];
|
|
3067
|
+
this._writeElementValueSync(displayValue);
|
|
2418
3068
|
// Let the service know we've finished writing value
|
|
2419
3069
|
this._maskService.writingValue = false;
|
|
2420
3070
|
this._maskService.isInitialized = true;
|
|
2421
3071
|
}
|
|
2422
3072
|
else {
|
|
2423
3073
|
this._maskService.formElementProperty = ['value', inputValue];
|
|
3074
|
+
this._writeElementValueSync(inputValue);
|
|
2424
3075
|
this._maskService.isInitialized = true;
|
|
2425
3076
|
}
|
|
2426
|
-
|
|
3077
|
+
// A writeValue-driven emission may have dirtied/touched the control via the
|
|
3078
|
+
// view-change pipeline; undo that so programmatic writes stay pristine.
|
|
3079
|
+
this._restoreControlStateAfterWrite(wasPristine, wasUntouched);
|
|
3080
|
+
// Programmatic writes (setValue/patchValue called outside a signal/effect context)
|
|
3081
|
+
// don't schedule CD under zoneless change detection. Request a check so bindings
|
|
3082
|
+
// reading form.pristine/form.dirty/form.value on the host template stay in sync.
|
|
3083
|
+
this._changeDetectorRef.markForCheck();
|
|
2427
3084
|
}
|
|
2428
3085
|
else {
|
|
2429
3086
|
// eslint-disable-next-line no-console
|
|
2430
3087
|
console.warn('Ngx-mask writeValue work with string | number, your current value:', typeof value);
|
|
2431
3088
|
}
|
|
2432
3089
|
}
|
|
3090
|
+
/**
|
|
3091
|
+
* Mirrors a writeValue-driven render into the DOM synchronously, in the same
|
|
3092
|
+
* change-detection pass (#1305). The service's `formElementProperty` setter defers all
|
|
3093
|
+
* writes via queueMicrotask (to keep FIFO ordering with config-driven re-renders and
|
|
3094
|
+
* dodge ExpressionChanged issues), but consumers that read `nativeElement.value` DURING
|
|
3095
|
+
* the CD pass — Angular Material's floating label (`MatInput.empty`), CDK autofill —
|
|
3096
|
+
* never see a value that only lands in a later microtask. The deferred write still runs
|
|
3097
|
+
* afterwards and re-applies the same final value, so ordering guarantees are preserved.
|
|
3098
|
+
*
|
|
3099
|
+
* Skipped while a mask reconfiguration is pending (`mask()` input changed but
|
|
3100
|
+
* `ngOnChanges` has not applied it yet — e.g. `mask.set(...)` + `setValue(...)` before
|
|
3101
|
+
* the next CD pass): the value just computed used the STALE mask config, and rendering
|
|
3102
|
+
* it synchronously would expose an intermediate state that the deferred pipeline is
|
|
3103
|
+
* about to supersede. Multi-masks (`||`) resolve `_maskValue` to one alternative and
|
|
3104
|
+
* therefore also fall back to the deferred-only path.
|
|
3105
|
+
*/
|
|
3106
|
+
_writeElementValueSync(value) {
|
|
3107
|
+
if ((this.mask() ?? MaskExpression.EMPTY_STRING) !== this._maskValue()) {
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
this._renderer.setProperty(this._elementRef.nativeElement, 'value', value);
|
|
3111
|
+
}
|
|
2433
3112
|
registerOnChange(fn) {
|
|
2434
|
-
//
|
|
3113
|
+
// Angular only calls this in classic ControlValueAccessor mode (reactive/template forms).
|
|
3114
|
+
// Its invocation is therefore our reliable signal that we are NOT in Signal Forms mode.
|
|
3115
|
+
this._isCvaMode.set(true);
|
|
2435
3116
|
const originalFn = fn;
|
|
2436
3117
|
this._maskService.onChange = this.onChange = (value) => {
|
|
2437
3118
|
originalFn(value);
|
|
2438
|
-
|
|
2439
|
-
if (this._isSignalFormsMode()) {
|
|
2440
|
-
const stringValue = value === null || typeof value === 'undefined' ? '' : String(value);
|
|
2441
|
-
if (this.value() !== stringValue) {
|
|
2442
|
-
this.value.set(stringValue);
|
|
2443
|
-
}
|
|
2444
|
-
}
|
|
3119
|
+
this._propagateToValueModel(value);
|
|
2445
3120
|
};
|
|
2446
3121
|
}
|
|
2447
3122
|
registerOnTouched(fn) {
|
|
2448
3123
|
this.onTouch = () => {
|
|
2449
3124
|
fn();
|
|
2450
|
-
|
|
2451
|
-
if (this._isSignalFormsMode() && !this.touched()) {
|
|
3125
|
+
if (!this.touched()) {
|
|
2452
3126
|
this.touched.set(true);
|
|
2453
3127
|
}
|
|
2454
3128
|
};
|
|
2455
3129
|
}
|
|
3130
|
+
/**
|
|
3131
|
+
* Pushes the current unmasked value into the `value` model input. In Signal Forms mode this
|
|
3132
|
+
* fires the `valueChange` output that Angular listens to; in CVA mode it is a harmless write
|
|
3133
|
+
* to a model nobody reads. We flag `_skipNextValueEffect` so the resulting model change does
|
|
3134
|
+
* not bounce back through the value effect and overwrite the raw `_inputValue`.
|
|
3135
|
+
*/
|
|
3136
|
+
_propagateToValueModel(value) {
|
|
3137
|
+
const stringValue = value === null || typeof value === 'undefined' ? '' : String(value);
|
|
3138
|
+
this._lastPropagatedValue = stringValue;
|
|
3139
|
+
untracked(() => {
|
|
3140
|
+
if (String(this.value()) !== stringValue) {
|
|
3141
|
+
this._skipNextValueEffect.set(true);
|
|
3142
|
+
this.value.set(stringValue);
|
|
3143
|
+
}
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
2456
3146
|
/**
|
|
2457
3147
|
* Focus the input element.
|
|
2458
3148
|
* Required by FormValueControl interface for Signal Forms.
|
|
@@ -2484,9 +3174,37 @@ class NgxMaskDirective {
|
|
|
2484
3174
|
this._maskService.maskExpression = this._maskService._repeatPatternSymbols(this._maskValue() || '');
|
|
2485
3175
|
this._maskService.formElementProperty = [
|
|
2486
3176
|
'value',
|
|
2487
|
-
this.
|
|
3177
|
+
this._maskedOrVerbatim(this._inputValue()),
|
|
2488
3178
|
];
|
|
2489
3179
|
}
|
|
3180
|
+
/**
|
|
3181
|
+
* Renders `inputValue` through the mask, falling back to the raw value verbatim when the
|
|
3182
|
+
* mask cannot process ANY of it (#1615, e.g. a sentinel like 'ONGOING' written into a
|
|
3183
|
+
* digits-only control). Values that PARTIALLY match keep regular masking.
|
|
3184
|
+
*
|
|
3185
|
+
* Shared by writeValue() and _applyMask() (called from every ngOnChanges pass, including
|
|
3186
|
+
* ones triggered by an UNRELATED input like `disabled`) so the verbatim verdict for a
|
|
3187
|
+
* value written once via writeValue() is not lost on a later re-render that replays the
|
|
3188
|
+
* same raw `inputValue` outside of writeValue — which would otherwise re-run regular
|
|
3189
|
+
* masking, produce an empty result, and emit it through onChange, clobbering the model.
|
|
3190
|
+
*
|
|
3191
|
+
* Excludes an actual mask RECONFIGURATION (`maskChanged`): when the mask itself just
|
|
3192
|
+
* changed, a value that no longer matches must clear through the regular path (see
|
|
3193
|
+
* trigger-on-mask-change.spec.ts) — verbatim passthrough only covers re-renders of the
|
|
3194
|
+
* SAME mask.
|
|
3195
|
+
*/
|
|
3196
|
+
_maskedOrVerbatim(inputValue) {
|
|
3197
|
+
// Snapshot before applyMask(): it resets maskChanged internally as part of emitting
|
|
3198
|
+
// (or skipping) the change, so it must be read before the call, not after.
|
|
3199
|
+
const wasMaskChanged = this._maskService.maskChanged;
|
|
3200
|
+
const maskedResult = this._maskService.applyMask(inputValue, this._maskService.maskExpression);
|
|
3201
|
+
return !maskedResult &&
|
|
3202
|
+
inputValue &&
|
|
3203
|
+
!wasMaskChanged &&
|
|
3204
|
+
this._maskService.removeMask(inputValue)
|
|
3205
|
+
? inputValue
|
|
3206
|
+
: maskedResult;
|
|
3207
|
+
}
|
|
2490
3208
|
_validateTime(value) {
|
|
2491
3209
|
const rowMaskLen = this._maskValue()
|
|
2492
3210
|
.split(MaskExpression.EMPTY_STRING)
|
|
@@ -2504,6 +3222,98 @@ class NgxMaskDirective {
|
|
|
2504
3222
|
return (this._maskService.actualValue.length ||
|
|
2505
3223
|
this._maskService.actualValue.length + this._maskService.prefix.length);
|
|
2506
3224
|
}
|
|
3225
|
+
/**
|
|
3226
|
+
* For `||` multi-masks only (#1583): a value shorter than the currently selected
|
|
3227
|
+
* alternative is still valid when it is a pattern-valid prefix of that alternative
|
|
3228
|
+
* ending exactly at a special-character boundary (e.g. `0` for `0,N`) and it meets
|
|
3229
|
+
* the length requirement of at least one alternative (e.g. `1`). Values stopping
|
|
3230
|
+
* mid-pattern-block (e.g. `112A` for `000SS`) remain invalid.
|
|
3231
|
+
*/
|
|
3232
|
+
_isCompleteAlternativeBoundary(processedValue) {
|
|
3233
|
+
const alternatives = this._maskExpressionArray();
|
|
3234
|
+
if (!alternatives.length) {
|
|
3235
|
+
return false;
|
|
3236
|
+
}
|
|
3237
|
+
const maskValue = this._maskValue();
|
|
3238
|
+
const cleanValue = this._maskService.removeMask(processedValue);
|
|
3239
|
+
const cleanMask = this._maskService.removeMask(maskValue);
|
|
3240
|
+
const isPatternPrefix = cleanValue
|
|
3241
|
+
.split(MaskExpression.EMPTY_STRING)
|
|
3242
|
+
.every((character, index) => this._maskService._checkSymbolMask(character, cleanMask.charAt(index)));
|
|
3243
|
+
if (!isPatternPrefix) {
|
|
3244
|
+
return false;
|
|
3245
|
+
}
|
|
3246
|
+
let patternCount = 0;
|
|
3247
|
+
let boundaryCharacter = MaskExpression.EMPTY_STRING;
|
|
3248
|
+
for (const maskCharacter of maskValue) {
|
|
3249
|
+
if (patternCount === cleanValue.length) {
|
|
3250
|
+
boundaryCharacter = maskCharacter;
|
|
3251
|
+
break;
|
|
3252
|
+
}
|
|
3253
|
+
if (!this._maskService.specialCharacters.includes(maskCharacter)) {
|
|
3254
|
+
patternCount++;
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
if (!boundaryCharacter ||
|
|
3258
|
+
!this._maskService.specialCharacters.includes(boundaryCharacter)) {
|
|
3259
|
+
return false;
|
|
3260
|
+
}
|
|
3261
|
+
return alternatives.some((alternative) => {
|
|
3262
|
+
const requiredLength = this._maskService.dropSpecialCharacters
|
|
3263
|
+
? alternative.length - this._maskService.checkDropSpecialCharAmount(alternative)
|
|
3264
|
+
: this.prefix()
|
|
3265
|
+
? alternative.length + this.prefix().length
|
|
3266
|
+
: alternative.length;
|
|
3267
|
+
return processedValue.length >= requiredLength;
|
|
3268
|
+
});
|
|
3269
|
+
}
|
|
3270
|
+
/**
|
|
3271
|
+
* True when every character of the mask expression is either a pattern token or a
|
|
3272
|
+
* special character — i.e. the mask has no quantifiers (`*`, `?`), curly-bracket
|
|
3273
|
+
* repetitions or other constructs the position-aware matcher does not model.
|
|
3274
|
+
*/
|
|
3275
|
+
_isPlainTokenMask(mask) {
|
|
3276
|
+
return mask
|
|
3277
|
+
.split(MaskExpression.EMPTY_STRING)
|
|
3278
|
+
.every((symbol) => !!this._maskService.patterns[symbol] ||
|
|
3279
|
+
this._maskService.specialCharacters.includes(symbol));
|
|
3280
|
+
}
|
|
3281
|
+
/**
|
|
3282
|
+
* Backtracking match of a value against a mask mixing optional and mandatory pattern
|
|
3283
|
+
* tokens (#1515, e.g. `999SSS`). Optional tokens may be left unfilled; special
|
|
3284
|
+
* characters may be absent from the value (dropSpecialCharacters). The value is valid
|
|
3285
|
+
* when it is fully consumed and every remaining mask token is optional or special.
|
|
3286
|
+
*/
|
|
3287
|
+
_matchesMaskWithOptionalSkip(value, mask) {
|
|
3288
|
+
const patterns = this._maskService.patterns;
|
|
3289
|
+
const match = (maskIndex, valueIndex) => {
|
|
3290
|
+
if (valueIndex === value.length) {
|
|
3291
|
+
return mask
|
|
3292
|
+
.slice(maskIndex)
|
|
3293
|
+
.split(MaskExpression.EMPTY_STRING)
|
|
3294
|
+
.every((symbol) => !patterns[symbol] || !!patterns[symbol]?.optional);
|
|
3295
|
+
}
|
|
3296
|
+
if (maskIndex === mask.length) {
|
|
3297
|
+
return false;
|
|
3298
|
+
}
|
|
3299
|
+
const maskSymbol = mask[maskIndex];
|
|
3300
|
+
const valueSymbol = value[valueIndex];
|
|
3301
|
+
const pattern = patterns[maskSymbol];
|
|
3302
|
+
if (pattern) {
|
|
3303
|
+
if (pattern.pattern.test(valueSymbol) && match(maskIndex + 1, valueIndex + 1)) {
|
|
3304
|
+
return true;
|
|
3305
|
+
}
|
|
3306
|
+
return !!pattern.optional && match(maskIndex + 1, valueIndex);
|
|
3307
|
+
}
|
|
3308
|
+
// Special character: consume it when present in the value, otherwise treat it
|
|
3309
|
+
// as dropped (dropSpecialCharacters).
|
|
3310
|
+
if (valueSymbol === maskSymbol && match(maskIndex + 1, valueIndex + 1)) {
|
|
3311
|
+
return true;
|
|
3312
|
+
}
|
|
3313
|
+
return match(maskIndex + 1, valueIndex);
|
|
3314
|
+
};
|
|
3315
|
+
return match(0, 0);
|
|
3316
|
+
}
|
|
2507
3317
|
_createValidationError(actualValue) {
|
|
2508
3318
|
return {
|
|
2509
3319
|
mask: {
|
|
@@ -2570,8 +3380,8 @@ class NgxMaskDirective {
|
|
|
2570
3380
|
return uniqueCharacters.size === 1;
|
|
2571
3381
|
});
|
|
2572
3382
|
}
|
|
2573
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
2574
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.
|
|
3383
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
3384
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.17", type: NgxMaskDirective, isStandalone: true, selector: "input[mask], textarea[mask]", inputs: { mask: { classPropertyName: "mask", publicName: "mask", isSignal: true, isRequired: false, transformFunction: null }, specialCharacters: { classPropertyName: "specialCharacters", publicName: "specialCharacters", isSignal: true, isRequired: false, transformFunction: null }, patterns: { classPropertyName: "patterns", publicName: "patterns", isSignal: true, isRequired: false, transformFunction: null }, prefix: { classPropertyName: "prefix", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null }, suffix: { classPropertyName: "suffix", publicName: "suffix", isSignal: true, isRequired: false, transformFunction: null }, thousandSeparator: { classPropertyName: "thousandSeparator", publicName: "thousandSeparator", isSignal: true, isRequired: false, transformFunction: null }, decimalMarker: { classPropertyName: "decimalMarker", publicName: "decimalMarker", isSignal: true, isRequired: false, transformFunction: null }, dropSpecialCharacters: { classPropertyName: "dropSpecialCharacters", publicName: "dropSpecialCharacters", isSignal: true, isRequired: false, transformFunction: null }, hiddenInput: { classPropertyName: "hiddenInput", publicName: "hiddenInput", isSignal: true, isRequired: false, transformFunction: null }, showMaskTyped: { classPropertyName: "showMaskTyped", publicName: "showMaskTyped", isSignal: true, isRequired: false, transformFunction: null }, placeHolderCharacter: { classPropertyName: "placeHolderCharacter", publicName: "placeHolderCharacter", isSignal: true, isRequired: false, transformFunction: null }, shownMaskExpression: { classPropertyName: "shownMaskExpression", publicName: "shownMaskExpression", isSignal: true, isRequired: false, transformFunction: null }, clearIfNotMatch: { classPropertyName: "clearIfNotMatch", publicName: "clearIfNotMatch", isSignal: true, isRequired: false, transformFunction: null }, validation: { classPropertyName: "validation", publicName: "validation", isSignal: true, isRequired: false, transformFunction: null }, separatorLimit: { classPropertyName: "separatorLimit", publicName: "separatorLimit", isSignal: true, isRequired: false, transformFunction: null }, allowNegativeNumbers: { classPropertyName: "allowNegativeNumbers", publicName: "allowNegativeNumbers", isSignal: true, isRequired: false, transformFunction: null }, leadZeroDateTime: { classPropertyName: "leadZeroDateTime", publicName: "leadZeroDateTime", isSignal: true, isRequired: false, transformFunction: null }, leadZero: { classPropertyName: "leadZero", publicName: "leadZero", isSignal: true, isRequired: false, transformFunction: null }, triggerOnMaskChange: { classPropertyName: "triggerOnMaskChange", publicName: "triggerOnMaskChange", isSignal: true, isRequired: false, transformFunction: null }, apm: { classPropertyName: "apm", publicName: "apm", isSignal: true, isRequired: false, transformFunction: null }, inputTransformFn: { classPropertyName: "inputTransformFn", publicName: "inputTransformFn", isSignal: true, isRequired: false, transformFunction: null }, outputTransformFn: { classPropertyName: "outputTransformFn", publicName: "outputTransformFn", isSignal: true, isRequired: false, transformFunction: null }, keepCharacterPositions: { classPropertyName: "keepCharacterPositions", publicName: "keepCharacterPositions", isSignal: true, isRequired: false, transformFunction: null }, instantPrefix: { classPropertyName: "instantPrefix", publicName: "instantPrefix", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, touched: { classPropertyName: "touched", publicName: "touched", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", touched: "touchedChange", maskFilled: "maskFilled" }, host: { listeners: { "paste": "onPaste()", "focus": "onFocus()", "ngModelChange": "onModelChange($event)", "input": "onInput($event)", "compositionstart": "onCompositionStart()", "compositionend": "onCompositionEnd($event)", "blur": "onBlur($event)", "click": "onClick($event)", "keydown": "onKeyDown($event)" } }, providers: [
|
|
2575
3385
|
{
|
|
2576
3386
|
provide: NG_VALUE_ACCESSOR,
|
|
2577
3387
|
useExisting: NgxMaskDirective,
|
|
@@ -2585,7 +3395,7 @@ class NgxMaskDirective {
|
|
|
2585
3395
|
NgxMaskService,
|
|
2586
3396
|
], exportAs: ["mask", "ngxMask"], usesOnChanges: true, ngImport: i0 });
|
|
2587
3397
|
}
|
|
2588
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
3398
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskDirective, decorators: [{
|
|
2589
3399
|
type: Directive,
|
|
2590
3400
|
args: [{
|
|
2591
3401
|
selector: 'input[mask], textarea[mask]',
|
|
@@ -2605,7 +3415,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
|
|
|
2605
3415
|
],
|
|
2606
3416
|
exportAs: 'mask,ngxMask',
|
|
2607
3417
|
}]
|
|
2608
|
-
}], ctorParameters: () => [], propDecorators: { mask: [{ type: i0.Input, args: [{ isSignal: true, alias: "mask", required: false }] }], specialCharacters: [{ type: i0.Input, args: [{ isSignal: true, alias: "specialCharacters", required: false }] }], patterns: [{ type: i0.Input, args: [{ isSignal: true, alias: "patterns", required: false }] }], prefix: [{ type: i0.Input, args: [{ isSignal: true, alias: "prefix", required: false }] }], suffix: [{ type: i0.Input, args: [{ isSignal: true, alias: "suffix", required: false }] }], thousandSeparator: [{ type: i0.Input, args: [{ isSignal: true, alias: "thousandSeparator", required: false }] }], decimalMarker: [{ type: i0.Input, args: [{ isSignal: true, alias: "decimalMarker", required: false }] }], dropSpecialCharacters: [{ type: i0.Input, args: [{ isSignal: true, alias: "dropSpecialCharacters", required: false }] }], hiddenInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "hiddenInput", required: false }] }], showMaskTyped: [{ type: i0.Input, args: [{ isSignal: true, alias: "showMaskTyped", required: false }] }], placeHolderCharacter: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeHolderCharacter", required: false }] }], shownMaskExpression: [{ type: i0.Input, args: [{ isSignal: true, alias: "shownMaskExpression", required: false }] }], clearIfNotMatch: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearIfNotMatch", required: false }] }], validation: [{ type: i0.Input, args: [{ isSignal: true, alias: "validation", required: false }] }], separatorLimit: [{ type: i0.Input, args: [{ isSignal: true, alias: "separatorLimit", required: false }] }], allowNegativeNumbers: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowNegativeNumbers", required: false }] }], leadZeroDateTime: [{ type: i0.Input, args: [{ isSignal: true, alias: "leadZeroDateTime", required: false }] }], leadZero: [{ type: i0.Input, args: [{ isSignal: true, alias: "leadZero", required: false }] }], triggerOnMaskChange: [{ type: i0.Input, args: [{ isSignal: true, alias: "triggerOnMaskChange", required: false }] }], apm: [{ type: i0.Input, args: [{ isSignal: true, alias: "apm", required: false }] }], inputTransformFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputTransformFn", required: false }] }], outputTransformFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "outputTransformFn", required: false }] }], keepCharacterPositions: [{ type: i0.Input, args: [{ isSignal: true, alias: "keepCharacterPositions", required: false }] }], instantPrefix: [{ type: i0.Input, args: [{ isSignal: true, alias: "instantPrefix", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }],
|
|
3418
|
+
}], ctorParameters: () => [], propDecorators: { mask: [{ type: i0.Input, args: [{ isSignal: true, alias: "mask", required: false }] }], specialCharacters: [{ type: i0.Input, args: [{ isSignal: true, alias: "specialCharacters", required: false }] }], patterns: [{ type: i0.Input, args: [{ isSignal: true, alias: "patterns", required: false }] }], prefix: [{ type: i0.Input, args: [{ isSignal: true, alias: "prefix", required: false }] }], suffix: [{ type: i0.Input, args: [{ isSignal: true, alias: "suffix", required: false }] }], thousandSeparator: [{ type: i0.Input, args: [{ isSignal: true, alias: "thousandSeparator", required: false }] }], decimalMarker: [{ type: i0.Input, args: [{ isSignal: true, alias: "decimalMarker", required: false }] }], dropSpecialCharacters: [{ type: i0.Input, args: [{ isSignal: true, alias: "dropSpecialCharacters", required: false }] }], hiddenInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "hiddenInput", required: false }] }], showMaskTyped: [{ type: i0.Input, args: [{ isSignal: true, alias: "showMaskTyped", required: false }] }], placeHolderCharacter: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeHolderCharacter", required: false }] }], shownMaskExpression: [{ type: i0.Input, args: [{ isSignal: true, alias: "shownMaskExpression", required: false }] }], clearIfNotMatch: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearIfNotMatch", required: false }] }], validation: [{ type: i0.Input, args: [{ isSignal: true, alias: "validation", required: false }] }], separatorLimit: [{ type: i0.Input, args: [{ isSignal: true, alias: "separatorLimit", required: false }] }], allowNegativeNumbers: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowNegativeNumbers", required: false }] }], leadZeroDateTime: [{ type: i0.Input, args: [{ isSignal: true, alias: "leadZeroDateTime", required: false }] }], leadZero: [{ type: i0.Input, args: [{ isSignal: true, alias: "leadZero", required: false }] }], triggerOnMaskChange: [{ type: i0.Input, args: [{ isSignal: true, alias: "triggerOnMaskChange", required: false }] }], apm: [{ type: i0.Input, args: [{ isSignal: true, alias: "apm", required: false }] }], inputTransformFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputTransformFn", required: false }] }], outputTransformFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "outputTransformFn", required: false }] }], keepCharacterPositions: [{ type: i0.Input, args: [{ isSignal: true, alias: "keepCharacterPositions", required: false }] }], instantPrefix: [{ type: i0.Input, args: [{ isSignal: true, alias: "instantPrefix", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], touched: [{ type: i0.Input, args: [{ isSignal: true, alias: "touched", required: false }] }, { type: i0.Output, args: ["touchedChange"] }], maskFilled: [{ type: i0.Output, args: ["maskFilled"] }], onPaste: [{
|
|
2609
3419
|
type: HostListener,
|
|
2610
3420
|
args: ['paste']
|
|
2611
3421
|
}], onFocus: [{
|
|
@@ -2718,10 +3528,10 @@ class NgxMaskPipe {
|
|
|
2718
3528
|
});
|
|
2719
3529
|
}
|
|
2720
3530
|
}
|
|
2721
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
2722
|
-
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.
|
|
3531
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
|
|
3532
|
+
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskPipe, isStandalone: true, name: "mask" });
|
|
2723
3533
|
}
|
|
2724
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
3534
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskPipe, decorators: [{
|
|
2725
3535
|
type: Pipe,
|
|
2726
3536
|
args: [{
|
|
2727
3537
|
name: 'mask',
|