ngx-mask 21.0.0 → 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 +1047 -222
- package/fesm2022/ngx-mask.mjs.map +1 -1
- package/package.json +6 -5
- 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;
|
|
@@ -636,6 +705,12 @@ class NgxMaskApplierService {
|
|
|
636
705
|
}
|
|
637
706
|
else if (this.patterns[maskExpression[cursor] ?? MaskExpression.EMPTY_STRING] &&
|
|
638
707
|
this.patterns[maskExpression[cursor] ?? MaskExpression.EMPTY_STRING]?.optional) {
|
|
708
|
+
// If the input symbol is whitespace or doesn't match the pattern,
|
|
709
|
+
// skip it without consuming the mask position
|
|
710
|
+
if (inputSymbol.trim() === MaskExpression.EMPTY_STRING) {
|
|
711
|
+
// Skip whitespace input, don't advance mask cursor
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
639
714
|
if (!!inputArray[cursor] &&
|
|
640
715
|
maskExpression !== '099.099.099.099' &&
|
|
641
716
|
maskExpression !== '000.000.000-00' &&
|
|
@@ -653,15 +728,22 @@ class NgxMaskApplierService {
|
|
|
653
728
|
i--;
|
|
654
729
|
}
|
|
655
730
|
else if (this.maskExpression[cursor + 1] === MaskExpression.SYMBOL_STAR &&
|
|
656
|
-
|
|
657
|
-
|
|
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]) &&
|
|
658
739
|
multi) {
|
|
659
740
|
cursor += 3;
|
|
660
741
|
result += inputSymbol;
|
|
661
742
|
}
|
|
662
743
|
else if (this.maskExpression[cursor + 1] === MaskExpression.SYMBOL_QUESTION &&
|
|
663
|
-
this._findSpecialChar(this.maskExpression[cursor + 2] ?? MaskExpression.EMPTY_STRING)
|
|
664
|
-
|
|
744
|
+
(this._findSpecialChar(this.maskExpression[cursor + 2] ?? MaskExpression.EMPTY_STRING)
|
|
745
|
+
? this._findSpecialChar(inputSymbol) === this.maskExpression[cursor + 2]
|
|
746
|
+
: inputSymbol === this.maskExpression[cursor + 2]) &&
|
|
665
747
|
multi) {
|
|
666
748
|
cursor += 3;
|
|
667
749
|
result += inputSymbol;
|
|
@@ -707,9 +789,20 @@ class NgxMaskApplierService {
|
|
|
707
789
|
const isSpecialCharacterMaskFirstSymbol = processedValue.length === 1 &&
|
|
708
790
|
this.specialCharacters.includes(maskExpression[0]) &&
|
|
709
791
|
processedValue !== maskExpression[0];
|
|
710
|
-
if (
|
|
711
|
-
|
|
712
|
-
|
|
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
|
+
}
|
|
713
806
|
}
|
|
714
807
|
if (result.includes(MaskExpression.MINUS) && this.prefix && this.allowNegativeNumbers) {
|
|
715
808
|
if (backspaced && result === MaskExpression.MINUS) {
|
|
@@ -788,11 +881,21 @@ class NgxMaskApplierService {
|
|
|
788
881
|
checkAndRemoveSuffix = (inputValue) => {
|
|
789
882
|
for (let i = this.suffix?.length - 1; i >= 0; i--) {
|
|
790
883
|
const substr = this.suffix.substring(i, this.suffix?.length);
|
|
791
|
-
if (inputValue.
|
|
884
|
+
if (inputValue.endsWith(substr) &&
|
|
792
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)) &&
|
|
793
896
|
(i - 1 < 0 ||
|
|
794
|
-
!inputValue.
|
|
795
|
-
return inputValue.
|
|
897
|
+
!inputValue.endsWith(this.suffix.substring(i - 1, this.suffix?.length)))) {
|
|
898
|
+
return inputValue.slice(0, inputValue.length - substr.length);
|
|
796
899
|
}
|
|
797
900
|
}
|
|
798
901
|
return inputValue;
|
|
@@ -906,10 +1009,10 @@ class NgxMaskApplierService {
|
|
|
906
1009
|
nonZeroIndex,
|
|
907
1010
|
};
|
|
908
1011
|
}
|
|
909
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
910
|
-
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 });
|
|
911
1014
|
}
|
|
912
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
1015
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskApplierService, decorators: [{
|
|
913
1016
|
type: Injectable
|
|
914
1017
|
}] });
|
|
915
1018
|
|
|
@@ -922,12 +1025,20 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
922
1025
|
maskExpressionArray = [];
|
|
923
1026
|
previousValue = '';
|
|
924
1027
|
currentValue = '';
|
|
1028
|
+
// `writingValue` is declared on NgxMaskApplierService: applyMask needs it to
|
|
1029
|
+
// distinguish the writeValue flow from keystroke flows (#1611).
|
|
1030
|
+
isInitialized = false;
|
|
925
1031
|
/**
|
|
926
|
-
*
|
|
927
|
-
*
|
|
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).
|
|
928
1039
|
*/
|
|
929
|
-
|
|
930
|
-
|
|
1040
|
+
keepCharacterPositionsHandled = null;
|
|
1041
|
+
_isFocused = signal(false, ...(ngDevMode ? [{ debugName: "_isFocused" }] : /* istanbul ignore next */ []));
|
|
931
1042
|
_emitValue = false;
|
|
932
1043
|
_start;
|
|
933
1044
|
_end;
|
|
@@ -950,10 +1061,35 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
950
1061
|
applyMask(inputValue, maskExpression, position = 0, justPasted = false, backspaced = false,
|
|
951
1062
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
952
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;
|
|
953
1069
|
// If no mask expression, return the input value or the actual value
|
|
954
1070
|
if (!maskExpression) {
|
|
955
1071
|
return inputValue !== this.actualValue ? this.actualValue : inputValue;
|
|
956
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
|
+
}
|
|
957
1093
|
// Show mask in input if required
|
|
958
1094
|
this.maskIsShown = this.showMaskTyped
|
|
959
1095
|
? this.showMaskInInput()
|
|
@@ -962,7 +1098,9 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
962
1098
|
if (this.maskExpression === MaskExpression.IP && this.showMaskTyped) {
|
|
963
1099
|
this.maskIsShown = this.showMaskInInput(inputValue || MaskExpression.HASH);
|
|
964
1100
|
}
|
|
965
|
-
|
|
1101
|
+
const isCpfCnpjMask = this.maskExpression === MaskExpression.CPF_CNPJ ||
|
|
1102
|
+
this.maskExpression === MaskExpression.CPF_CNPJ_ALPHA;
|
|
1103
|
+
if (isCpfCnpjMask && this.showMaskTyped) {
|
|
966
1104
|
this.maskIsShown = this.showMaskInInput(inputValue || MaskExpression.HASH);
|
|
967
1105
|
}
|
|
968
1106
|
// Handle empty input value with mask typed
|
|
@@ -979,7 +1117,11 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
979
1117
|
if ((this.hiddenInput ||
|
|
980
1118
|
(inputValue && inputValue.indexOf(MaskExpression.SYMBOL_STAR) >= 0)) &&
|
|
981
1119
|
!this.writingValue) {
|
|
982
|
-
|
|
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
|
|
983
1125
|
? inputValue.split(MaskExpression.EMPTY_STRING)
|
|
984
1126
|
: this.actualValue.split(MaskExpression.EMPTY_STRING);
|
|
985
1127
|
// Handle backspace
|
|
@@ -1079,8 +1221,10 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1079
1221
|
newInputValue =
|
|
1080
1222
|
Boolean(newInputValue) && newInputValue.length ? newInputValue : inputValue;
|
|
1081
1223
|
}
|
|
1082
|
-
// Handle
|
|
1083
|
-
|
|
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) &&
|
|
1084
1228
|
this.keepCharacterPositions &&
|
|
1085
1229
|
this.actualValue &&
|
|
1086
1230
|
!justPasted &&
|
|
@@ -1095,6 +1239,27 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1095
1239
|
}
|
|
1096
1240
|
// Apply the mask using the parent class method
|
|
1097
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
|
+
}
|
|
1098
1263
|
this.actualValue = this.getActualValue(result);
|
|
1099
1264
|
// handle some separator implications:
|
|
1100
1265
|
// a.) adjust decimalMarker default (. -> ,) if thousandSeparator is a dot
|
|
@@ -1138,14 +1303,19 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1138
1303
|
const resLen = result.length;
|
|
1139
1304
|
const prefNmask = `${this.prefix}${this.maskIsShown}${this.suffix}`;
|
|
1140
1305
|
// Handle specific mask expressions
|
|
1141
|
-
|
|
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)) {
|
|
1142
1316
|
const countSkipedSymbol = this._numberSkipedSymbols(result);
|
|
1143
1317
|
return `${result}${prefNmask.slice(resLen + countSkipedSymbol)}`;
|
|
1144
1318
|
}
|
|
1145
|
-
else if (this.maskExpression === MaskExpression.IP ||
|
|
1146
|
-
this.maskExpression === MaskExpression.CPF_CNPJ) {
|
|
1147
|
-
return `${result}${prefNmask}`;
|
|
1148
|
-
}
|
|
1149
1319
|
return `${result}${prefNmask.slice(resLen)}`;
|
|
1150
1320
|
}
|
|
1151
1321
|
// get the number of characters that were shifted
|
|
@@ -1235,12 +1405,34 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1235
1405
|
String(value).length > 14)) {
|
|
1236
1406
|
return String(value);
|
|
1237
1407
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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)}`;
|
|
1244
1436
|
}
|
|
1245
1437
|
showMaskInInput(inputVal) {
|
|
1246
1438
|
if (this.showMaskTyped && !!this.shownMaskExpression) {
|
|
@@ -1256,7 +1448,8 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1256
1448
|
if (this.maskExpression === MaskExpression.IP) {
|
|
1257
1449
|
return this._checkForIp(inputVal);
|
|
1258
1450
|
}
|
|
1259
|
-
if (this.maskExpression === MaskExpression.CPF_CNPJ
|
|
1451
|
+
if (this.maskExpression === MaskExpression.CPF_CNPJ ||
|
|
1452
|
+
this.maskExpression === MaskExpression.CPF_CNPJ_ALPHA) {
|
|
1260
1453
|
return this._checkForCpfCnpj(inputVal);
|
|
1261
1454
|
}
|
|
1262
1455
|
}
|
|
@@ -1342,28 +1535,41 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1342
1535
|
if (inputVal === MaskExpression.HASH) {
|
|
1343
1536
|
return cpf;
|
|
1344
1537
|
}
|
|
1345
|
-
const
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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);
|
|
1351
1548
|
}
|
|
1352
|
-
if (
|
|
1353
|
-
arr.
|
|
1549
|
+
if (arr.length > 2 && arr.length <= 5) {
|
|
1550
|
+
return cnpj.slice(arr.length + 1, cnpj.length);
|
|
1354
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);
|
|
1355
1559
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
+
}
|
|
1367
1573
|
}
|
|
1368
1574
|
if (arr.length === 11) {
|
|
1369
1575
|
return '';
|
|
@@ -1379,6 +1585,22 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1379
1585
|
}
|
|
1380
1586
|
return '';
|
|
1381
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
|
+
}
|
|
1382
1604
|
/**
|
|
1383
1605
|
* Recursively determine the current active element by navigating the Shadow DOM until the Active Element is found.
|
|
1384
1606
|
*/
|
|
@@ -1407,6 +1629,20 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1407
1629
|
if (!this.isInitialized && this._emitValue) {
|
|
1408
1630
|
return;
|
|
1409
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
|
+
}
|
|
1410
1646
|
if (Array.isArray(this.dropSpecialCharacters)) {
|
|
1411
1647
|
this.onChange(outputTransformFn(this._toNumber(this._checkSymbols(this._removeMask(this._removeSuffix(this._removePrefix(inputValue)), this.dropSpecialCharacters)))));
|
|
1412
1648
|
}
|
|
@@ -1539,17 +1775,59 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1539
1775
|
_checkPrecision(separatorExpression, separatorValue) {
|
|
1540
1776
|
const separatorPrecision = this.getPrecision(separatorExpression);
|
|
1541
1777
|
let value = separatorValue;
|
|
1542
|
-
if (separatorExpression.indexOf('2') > 0 ||
|
|
1543
|
-
(this.leadZero &&
|
|
1778
|
+
if ((separatorExpression.indexOf('2') > 0 && !this._isFocused()) ||
|
|
1779
|
+
(this.leadZero &&
|
|
1780
|
+
!this._isFocused() &&
|
|
1781
|
+
Number(separatorPrecision) > 0 &&
|
|
1782
|
+
Number.isFinite(separatorPrecision))) {
|
|
1544
1783
|
if (this.decimalMarker === MaskExpression.COMMA && this.leadZero) {
|
|
1545
1784
|
value = value.replace(',', '.');
|
|
1546
1785
|
}
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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);
|
|
1550
1795
|
}
|
|
1551
1796
|
return this.numberToString(value);
|
|
1552
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
|
+
}
|
|
1553
1831
|
_repeatPatternSymbols(maskExp) {
|
|
1554
1832
|
return ((maskExp.match(/{[0-9]+}/) &&
|
|
1555
1833
|
maskExp
|
|
@@ -1576,13 +1854,25 @@ class NgxMaskService extends NgxMaskApplierService {
|
|
|
1576
1854
|
}, '')) ||
|
|
1577
1855
|
maskExp);
|
|
1578
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
|
+
*/
|
|
1579
1869
|
currentLocaleDecimalMarker() {
|
|
1580
|
-
return
|
|
1870
|
+
return MaskExpression.DOT;
|
|
1581
1871
|
}
|
|
1582
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
1583
|
-
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 });
|
|
1584
1874
|
}
|
|
1585
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
1875
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskService, decorators: [{
|
|
1586
1876
|
type: Injectable
|
|
1587
1877
|
}] });
|
|
1588
1878
|
|
|
@@ -1619,82 +1909,160 @@ function provideEnvironmentNgxMask(configValue) {
|
|
|
1619
1909
|
|
|
1620
1910
|
class NgxMaskDirective {
|
|
1621
1911
|
// ===== Mask Configuration Inputs =====
|
|
1622
|
-
mask = input('', ...(ngDevMode ? [{ debugName: "mask" }] : []));
|
|
1623
|
-
specialCharacters = input([], ...(ngDevMode ? [{ debugName: "specialCharacters" }] : []));
|
|
1624
|
-
patterns = input({}, ...(ngDevMode ? [{ debugName: "patterns" }] : []));
|
|
1625
|
-
prefix = input('', ...(ngDevMode ? [{ debugName: "prefix" }] : []));
|
|
1626
|
-
suffix = input('', ...(ngDevMode ? [{ debugName: "suffix" }] : []));
|
|
1627
|
-
thousandSeparator = input(' ', ...(ngDevMode ? [{ debugName: "thousandSeparator" }] : []));
|
|
1628
|
-
decimalMarker = input('.', ...(ngDevMode ? [{ debugName: "decimalMarker" }] : []));
|
|
1629
|
-
dropSpecialCharacters = input(null, ...(ngDevMode ? [{ debugName: "dropSpecialCharacters" }] : []));
|
|
1630
|
-
hiddenInput = input(null, ...(ngDevMode ? [{ debugName: "hiddenInput" }] : []));
|
|
1631
|
-
showMaskTyped = input(null, ...(ngDevMode ? [{ debugName: "showMaskTyped" }] : []));
|
|
1632
|
-
placeHolderCharacter = input(null, ...(ngDevMode ? [{ debugName: "placeHolderCharacter" }] : []));
|
|
1633
|
-
shownMaskExpression = input(null, ...(ngDevMode ? [{ debugName: "shownMaskExpression" }] : []));
|
|
1634
|
-
clearIfNotMatch = input(null, ...(ngDevMode ? [{ debugName: "clearIfNotMatch" }] : []));
|
|
1635
|
-
validation = input(null, ...(ngDevMode ? [{ debugName: "validation" }] : []));
|
|
1636
|
-
separatorLimit = input('', ...(ngDevMode ? [{ debugName: "separatorLimit" }] : []));
|
|
1637
|
-
allowNegativeNumbers = input(null, ...(ngDevMode ? [{ debugName: "allowNegativeNumbers" }] : []));
|
|
1638
|
-
leadZeroDateTime = input(null, ...(ngDevMode ? [{ debugName: "leadZeroDateTime" }] : []));
|
|
1639
|
-
leadZero = input(null, ...(ngDevMode ? [{ debugName: "leadZero" }] : []));
|
|
1640
|
-
triggerOnMaskChange = input(null, ...(ngDevMode ? [{ debugName: "triggerOnMaskChange" }] : []));
|
|
1641
|
-
apm = input(null, ...(ngDevMode ? [{ debugName: "apm" }] : []));
|
|
1642
|
-
inputTransformFn = input(null, ...(ngDevMode ? [{ debugName: "inputTransformFn" }] : []));
|
|
1643
|
-
outputTransformFn = input(null, ...(ngDevMode ? [{ debugName: "outputTransformFn" }] : []));
|
|
1644
|
-
keepCharacterPositions = input(null, ...(ngDevMode ? [{ debugName: "keepCharacterPositions" }] : []));
|
|
1645
|
-
instantPrefix = input(null, ...(ngDevMode ? [{ debugName: "instantPrefix" }] : []));
|
|
1646
|
-
value = model('', ...(ngDevMode ? [{ debugName: "value" }] : []));
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
touched = model(false, ...(ngDevMode ? [{ debugName: "touched" }] : []));
|
|
1650
|
-
dirty = input(false, ...(ngDevMode ? [{ debugName: "dirty" }] : []));
|
|
1651
|
-
invalid = input(false, ...(ngDevMode ? [{ debugName: "invalid" }] : []));
|
|
1652
|
-
pending = input(false, ...(ngDevMode ? [{ debugName: "pending" }] : []));
|
|
1653
|
-
readonly = input(false, ...(ngDevMode ? [{ debugName: "readonly" }] : []));
|
|
1654
|
-
required = input(false, ...(ngDevMode ? [{ debugName: "required" }] : []));
|
|
1655
|
-
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 */ []));
|
|
1656
1939
|
maskFilled = output();
|
|
1657
|
-
_maskValue = signal('', ...(ngDevMode ? [{ debugName: "_maskValue" }] : []));
|
|
1658
|
-
_inputValue = signal('', ...(ngDevMode ? [{ debugName: "_inputValue" }] : []));
|
|
1659
|
-
_position = signal(null, ...(ngDevMode ? [{ debugName: "_position" }] : []));
|
|
1660
|
-
_code = signal('', ...(ngDevMode ? [{ debugName: "_code" }] : []));
|
|
1661
|
-
_maskExpressionArray = signal([], ...(ngDevMode ? [{ debugName: "_maskExpressionArray" }] : []));
|
|
1662
|
-
_justPasted = signal(false, ...(ngDevMode ? [{ debugName: "_justPasted" }] : []));
|
|
1663
|
-
_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 */ []));
|
|
1664
1947
|
/** For IME composition event */
|
|
1665
|
-
_isComposing = signal(false, ...(ngDevMode ? [{ debugName: "_isComposing" }] : []));
|
|
1666
|
-
/**
|
|
1667
|
-
|
|
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;
|
|
1668
1996
|
_maskService = inject(NgxMaskService, { self: true });
|
|
1669
1997
|
document = inject(DOCUMENT);
|
|
1670
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
|
+
}
|
|
1671
2022
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
1672
2023
|
onChange = (_) => { };
|
|
1673
2024
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
1674
2025
|
onTouch = () => { };
|
|
1675
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.
|
|
1676
2037
|
effect(() => {
|
|
1677
2038
|
const signalValue = this.value();
|
|
1678
|
-
|
|
1679
|
-
|
|
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())) {
|
|
1680
2048
|
this.writeValue(signalValue);
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
1683
2051
|
});
|
|
1684
2052
|
effect(() => {
|
|
1685
2053
|
const isDisabled = this.disabled();
|
|
1686
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;
|
|
1687
2062
|
this.setDisabledState(isDisabled);
|
|
1688
2063
|
});
|
|
1689
2064
|
});
|
|
1690
2065
|
}
|
|
1691
|
-
ngOnInit() {
|
|
1692
|
-
// Detect if we're being used with Signal Forms
|
|
1693
|
-
// Signal Forms will set the value model from outside
|
|
1694
|
-
if (this.value() !== '') {
|
|
1695
|
-
this._isSignalFormsMode.set(true);
|
|
1696
|
-
}
|
|
1697
|
-
}
|
|
1698
2066
|
ngOnChanges(changes) {
|
|
1699
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;
|
|
1700
2068
|
if (mask) {
|
|
@@ -1714,12 +2082,12 @@ class NgxMaskDirective {
|
|
|
1714
2082
|
}
|
|
1715
2083
|
}
|
|
1716
2084
|
if (specialCharacters) {
|
|
1717
|
-
if (
|
|
1718
|
-
|
|
1719
|
-
}
|
|
1720
|
-
else {
|
|
1721
|
-
this._maskService.specialCharacters = specialCharacters.currentValue || [];
|
|
2085
|
+
if (Array.isArray(specialCharacters.currentValue)) {
|
|
2086
|
+
this._maskService.specialCharacters = specialCharacters.currentValue;
|
|
1722
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).
|
|
1723
2091
|
}
|
|
1724
2092
|
if (allowNegativeNumbers) {
|
|
1725
2093
|
this._maskService.allowNegativeNumbers = allowNegativeNumbers.currentValue;
|
|
@@ -1791,6 +2159,13 @@ class NgxMaskDirective {
|
|
|
1791
2159
|
}
|
|
1792
2160
|
if (placeHolderCharacter) {
|
|
1793
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
|
+
}
|
|
1794
2169
|
}
|
|
1795
2170
|
if (shownMaskExpression) {
|
|
1796
2171
|
this._maskService.shownMaskExpression = shownMaskExpression.currentValue;
|
|
@@ -1823,6 +2198,17 @@ class NgxMaskDirective {
|
|
|
1823
2198
|
this._maskService.keepCharacterPositions = keepCharacterPositions.currentValue;
|
|
1824
2199
|
}
|
|
1825
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
|
+
}
|
|
1826
2212
|
}
|
|
1827
2213
|
validate({ value }) {
|
|
1828
2214
|
const processedValue = typeof value === 'number' ? String(value) : value;
|
|
@@ -1881,9 +2267,31 @@ class NgxMaskDirective {
|
|
|
1881
2267
|
else if (maskValue.indexOf(key) !== -1) {
|
|
1882
2268
|
counterOfOpt++;
|
|
1883
2269
|
}
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
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`).
|
|
1887
2295
|
}
|
|
1888
2296
|
if (counterOfOpt === maskValue.length) {
|
|
1889
2297
|
return null;
|
|
@@ -1908,6 +2316,13 @@ class NgxMaskDirective {
|
|
|
1908
2316
|
: maskValue.length - counterOfOpt;
|
|
1909
2317
|
if (array.length === 1) {
|
|
1910
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
|
+
}
|
|
1911
2326
|
return this._createValidationError(processedValue);
|
|
1912
2327
|
}
|
|
1913
2328
|
}
|
|
@@ -1950,6 +2365,7 @@ class NgxMaskDirective {
|
|
|
1950
2365
|
}
|
|
1951
2366
|
onFocus() {
|
|
1952
2367
|
this._isFocused.set(true);
|
|
2368
|
+
this._maskService._isFocused.set(true);
|
|
1953
2369
|
}
|
|
1954
2370
|
onModelChange(value) {
|
|
1955
2371
|
// on form reset we need to update the actualValue
|
|
@@ -1962,8 +2378,30 @@ class NgxMaskDirective {
|
|
|
1962
2378
|
}
|
|
1963
2379
|
onInput(e) {
|
|
1964
2380
|
this._maskService.isInitialized = true;
|
|
1965
|
-
//
|
|
1966
|
-
|
|
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()) {
|
|
1967
2405
|
return;
|
|
1968
2406
|
}
|
|
1969
2407
|
const el = e.target;
|
|
@@ -1972,51 +2410,90 @@ class NgxMaskDirective {
|
|
|
1972
2410
|
: el.value;
|
|
1973
2411
|
if (el.type !== 'number') {
|
|
1974
2412
|
if (typeof transformedValue === 'string' || typeof transformedValue === 'number') {
|
|
1975
|
-
|
|
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
|
+
}
|
|
1976
2423
|
this._inputValue.set(el.value);
|
|
1977
2424
|
this._setMask();
|
|
1978
2425
|
if (!this._maskValue()) {
|
|
1979
2426
|
this.onChange(el.value);
|
|
1980
2427
|
return;
|
|
1981
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);
|
|
1982
2436
|
let position = el.selectionStart === 1
|
|
1983
2437
|
? el.selectionStart + this._maskService.prefix.length
|
|
1984
2438
|
: el.selectionStart;
|
|
1985
|
-
if (this.
|
|
1986
|
-
this.
|
|
1987
|
-
this.
|
|
2439
|
+
if (this.keepCharacterPositions() &&
|
|
2440
|
+
this._maskService.placeHolderCharacter.length === 1 &&
|
|
2441
|
+
!this._justPasted()) {
|
|
1988
2442
|
const suffix = this.suffix();
|
|
1989
2443
|
const prefix = this.prefix();
|
|
1990
2444
|
const inputSymbol = el.value.slice(position - 1, position);
|
|
1991
2445
|
const prefixLength = prefix.length;
|
|
1992
|
-
const
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
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;
|
|
1999
2460
|
const backspaceOrDelete = this._code() === MaskExpression.BACKSPACE ||
|
|
2000
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;
|
|
2001
2467
|
if (backspaceOrDelete) {
|
|
2002
|
-
if (
|
|
2003
|
-
|
|
2004
|
-
|
|
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('')}`;
|
|
2005
2479
|
}
|
|
2006
2480
|
else if (this._maskService.selStart ===
|
|
2007
|
-
|
|
2008
|
-
this._maskService.actualValue = `${this._inputValue()}${
|
|
2481
|
+
maskSkeleton.length + prefixLength) {
|
|
2482
|
+
this._maskService.actualValue = `${this._inputValue()}${maskSkeleton.slice(selStart, selEnd)}`;
|
|
2009
2483
|
}
|
|
2010
2484
|
else {
|
|
2011
2485
|
this._maskService.actualValue = `${prefix}${this._inputValue()
|
|
2012
2486
|
.split(prefix)
|
|
2013
2487
|
.join('')
|
|
2014
|
-
.slice(0, selStart)}${
|
|
2488
|
+
.slice(0, selStart)}${maskSkeleton.slice(selStart, selEnd)}${this._maskService.actualValue.slice(selEnd + prefixLength, maskSkeleton.length + prefixLength)}${suffix}`;
|
|
2015
2489
|
}
|
|
2016
2490
|
}
|
|
2017
|
-
else if (!this._maskService.specialCharacters.includes(
|
|
2018
|
-
|
|
2019
|
-
|
|
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) {
|
|
2020
2497
|
this._maskService.actualValue = `${prefix}${this._maskService.placeHolderCharacter}${el.value
|
|
2021
2498
|
.split(prefix)
|
|
2022
2499
|
.join('')
|
|
@@ -2032,35 +2509,90 @@ class NgxMaskDirective {
|
|
|
2032
2509
|
}
|
|
2033
2510
|
position = this._code() === MaskExpression.DELETE ? position + 1 : position;
|
|
2034
2511
|
}
|
|
2035
|
-
if (
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
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;
|
|
2044
2522
|
}
|
|
2045
|
-
else
|
|
2046
|
-
|
|
2047
|
-
|
|
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;
|
|
2048
2535
|
}
|
|
2049
2536
|
else {
|
|
2050
|
-
this._maskService.actualValue =
|
|
2051
|
-
|
|
2052
|
-
.split(suffix)
|
|
2053
|
-
.join('')}${suffix}`;
|
|
2537
|
+
this._maskService.actualValue = blanked;
|
|
2538
|
+
position = selStartAbs;
|
|
2054
2539
|
}
|
|
2055
2540
|
}
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
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
|
+
}
|
|
2062
2581
|
}
|
|
2063
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;
|
|
2064
2596
|
}
|
|
2065
2597
|
let caretShift = 0;
|
|
2066
2598
|
let backspaceShift = false;
|
|
@@ -2138,11 +2670,30 @@ class NgxMaskDirective {
|
|
|
2138
2670
|
(this._code() === MaskExpression.BACKSPACE && !backspaceShift
|
|
2139
2671
|
? 0
|
|
2140
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
|
+
}
|
|
2141
2679
|
if (positionToApply > this._getActualInputLength()) {
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
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();
|
|
2146
2697
|
}
|
|
2147
2698
|
if (positionToApply < 0) {
|
|
2148
2699
|
positionToApply = 0;
|
|
@@ -2163,6 +2714,29 @@ class NgxMaskDirective {
|
|
|
2163
2714
|
this._maskService.applyValueChanges(el.value.length, this._justPasted(), this._code() === MaskExpression.BACKSPACE || this._code() === MaskExpression.DELETE);
|
|
2164
2715
|
}
|
|
2165
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
|
+
}
|
|
2166
2740
|
// IME starts
|
|
2167
2741
|
onCompositionStart() {
|
|
2168
2742
|
this._isComposing.set(true);
|
|
@@ -2170,6 +2744,12 @@ class NgxMaskDirective {
|
|
|
2170
2744
|
// IME completes
|
|
2171
2745
|
onCompositionEnd(e) {
|
|
2172
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
|
+
}
|
|
2173
2753
|
this._justPasted.set(true);
|
|
2174
2754
|
this.onInput(e);
|
|
2175
2755
|
}
|
|
@@ -2188,18 +2768,20 @@ class NgxMaskDirective {
|
|
|
2188
2768
|
const decimalPart = el.value.split(decimalMarker)[1];
|
|
2189
2769
|
el.value = el.value.includes(decimalMarker)
|
|
2190
2770
|
? el.value +
|
|
2191
|
-
MaskExpression.NUMBER_ZERO.repeat(precision - decimalPart
|
|
2771
|
+
MaskExpression.NUMBER_ZERO.repeat(precision - (decimalPart?.length || 0)) +
|
|
2192
2772
|
suffix
|
|
2193
2773
|
: el.value +
|
|
2194
2774
|
decimalMarker +
|
|
2195
2775
|
MaskExpression.NUMBER_ZERO.repeat(precision) +
|
|
2196
2776
|
suffix;
|
|
2197
2777
|
this._maskService.actualValue = el.value;
|
|
2778
|
+
this.onChange(this._maskService.actualValue);
|
|
2198
2779
|
}
|
|
2199
2780
|
}
|
|
2200
2781
|
this._maskService.clearIfNotMatchFn();
|
|
2201
2782
|
}
|
|
2202
2783
|
this._isFocused.set(false);
|
|
2784
|
+
this._maskService._isFocused.set(false);
|
|
2203
2785
|
this.onTouch();
|
|
2204
2786
|
}
|
|
2205
2787
|
onClick(e) {
|
|
@@ -2263,7 +2845,15 @@ class NgxMaskDirective {
|
|
|
2263
2845
|
// User finalize their choice from IME composition, so trigger onInput() for the composed text.
|
|
2264
2846
|
if (e.key === 'Enter') {
|
|
2265
2847
|
this.onCompositionEnd(event);
|
|
2848
|
+
return;
|
|
2266
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;
|
|
2267
2857
|
return;
|
|
2268
2858
|
}
|
|
2269
2859
|
this._code.set(e.code ? e.code : e.key);
|
|
@@ -2283,10 +2873,10 @@ class NgxMaskDirective {
|
|
|
2283
2873
|
}
|
|
2284
2874
|
if (e.key === MaskExpression.BACKSPACE && el.selectionStart !== 0) {
|
|
2285
2875
|
const prefixLength = this.prefix().length;
|
|
2286
|
-
//
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
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;
|
|
2290
2880
|
if (prefixLength > 1 && el.selectionStart <= prefixLength) {
|
|
2291
2881
|
el.setSelectionRange(prefixLength, el.selectionEnd);
|
|
2292
2882
|
}
|
|
@@ -2315,8 +2905,17 @@ class NgxMaskDirective {
|
|
|
2315
2905
|
cursorStart === 0 &&
|
|
2316
2906
|
el.selectionEnd === el.value.length &&
|
|
2317
2907
|
el.value.length !== 0) {
|
|
2318
|
-
|
|
2319
|
-
|
|
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);
|
|
2320
2919
|
}
|
|
2321
2920
|
}
|
|
2322
2921
|
if (!!this.suffix() &&
|
|
@@ -2334,8 +2933,53 @@ class NgxMaskDirective {
|
|
|
2334
2933
|
this._maskService.selEnd = el.selectionEnd;
|
|
2335
2934
|
}
|
|
2336
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
|
+
}
|
|
2337
2959
|
/** It writes the value in the input */
|
|
2338
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;
|
|
2339
2983
|
let value = controlValue;
|
|
2340
2984
|
const inputTransformFn = this._maskService.inputTransformFn;
|
|
2341
2985
|
if (typeof value === 'object' && value !== null && 'value' in value) {
|
|
@@ -2380,8 +3024,30 @@ class NgxMaskDirective {
|
|
|
2380
3024
|
.replace(MaskExpression.DOT, MaskExpression.COMMA);
|
|
2381
3025
|
}
|
|
2382
3026
|
if (this.mask()?.startsWith(MaskExpression.SEPARATOR) && this.leadZero()) {
|
|
3027
|
+
const isFirstWrite = !this._maskService.isInitialized;
|
|
2383
3028
|
requestAnimationFrame(() => {
|
|
3029
|
+
// On initial load, temporarily set isInitialized to false
|
|
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.
|
|
3037
|
+
if (isFirstWrite) {
|
|
3038
|
+
this._maskService.isInitialized = false;
|
|
3039
|
+
}
|
|
2384
3040
|
this._maskService.applyMask(inputValue?.toString() ?? '', this._maskService.maskExpression);
|
|
3041
|
+
if (isFirstWrite) {
|
|
3042
|
+
this._maskService.isInitialized = true;
|
|
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();
|
|
2385
3051
|
});
|
|
2386
3052
|
}
|
|
2387
3053
|
this._maskService.isNumberValue = true;
|
|
@@ -2396,48 +3062,87 @@ class NgxMaskDirective {
|
|
|
2396
3062
|
(this._maskService.prefix || this._maskService.showMaskTyped))) {
|
|
2397
3063
|
// Let the service we know we are writing value so that triggering onChange function won't happen during applyMask
|
|
2398
3064
|
this._maskService.writingValue = true;
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
];
|
|
3065
|
+
const displayValue = this._maskedOrVerbatim(inputValue);
|
|
3066
|
+
this._maskService.formElementProperty = ['value', displayValue];
|
|
3067
|
+
this._writeElementValueSync(displayValue);
|
|
2403
3068
|
// Let the service know we've finished writing value
|
|
2404
3069
|
this._maskService.writingValue = false;
|
|
2405
3070
|
this._maskService.isInitialized = true;
|
|
2406
3071
|
}
|
|
2407
3072
|
else {
|
|
2408
3073
|
this._maskService.formElementProperty = ['value', inputValue];
|
|
3074
|
+
this._writeElementValueSync(inputValue);
|
|
2409
3075
|
this._maskService.isInitialized = true;
|
|
2410
3076
|
}
|
|
2411
|
-
|
|
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();
|
|
2412
3084
|
}
|
|
2413
3085
|
else {
|
|
2414
3086
|
// eslint-disable-next-line no-console
|
|
2415
3087
|
console.warn('Ngx-mask writeValue work with string | number, your current value:', typeof value);
|
|
2416
3088
|
}
|
|
2417
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
|
+
}
|
|
2418
3112
|
registerOnChange(fn) {
|
|
2419
|
-
//
|
|
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);
|
|
2420
3116
|
const originalFn = fn;
|
|
2421
3117
|
this._maskService.onChange = this.onChange = (value) => {
|
|
2422
3118
|
originalFn(value);
|
|
2423
|
-
|
|
2424
|
-
if (this._isSignalFormsMode()) {
|
|
2425
|
-
const stringValue = value === null || typeof value === 'undefined' ? '' : String(value);
|
|
2426
|
-
if (this.value() !== stringValue) {
|
|
2427
|
-
this.value.set(stringValue);
|
|
2428
|
-
}
|
|
2429
|
-
}
|
|
3119
|
+
this._propagateToValueModel(value);
|
|
2430
3120
|
};
|
|
2431
3121
|
}
|
|
2432
3122
|
registerOnTouched(fn) {
|
|
2433
3123
|
this.onTouch = () => {
|
|
2434
3124
|
fn();
|
|
2435
|
-
|
|
2436
|
-
if (this._isSignalFormsMode() && !this.touched()) {
|
|
3125
|
+
if (!this.touched()) {
|
|
2437
3126
|
this.touched.set(true);
|
|
2438
3127
|
}
|
|
2439
3128
|
};
|
|
2440
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
|
+
}
|
|
2441
3146
|
/**
|
|
2442
3147
|
* Focus the input element.
|
|
2443
3148
|
* Required by FormValueControl interface for Signal Forms.
|
|
@@ -2469,9 +3174,37 @@ class NgxMaskDirective {
|
|
|
2469
3174
|
this._maskService.maskExpression = this._maskService._repeatPatternSymbols(this._maskValue() || '');
|
|
2470
3175
|
this._maskService.formElementProperty = [
|
|
2471
3176
|
'value',
|
|
2472
|
-
this.
|
|
3177
|
+
this._maskedOrVerbatim(this._inputValue()),
|
|
2473
3178
|
];
|
|
2474
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
|
+
}
|
|
2475
3208
|
_validateTime(value) {
|
|
2476
3209
|
const rowMaskLen = this._maskValue()
|
|
2477
3210
|
.split(MaskExpression.EMPTY_STRING)
|
|
@@ -2489,6 +3222,98 @@ class NgxMaskDirective {
|
|
|
2489
3222
|
return (this._maskService.actualValue.length ||
|
|
2490
3223
|
this._maskService.actualValue.length + this._maskService.prefix.length);
|
|
2491
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
|
+
}
|
|
2492
3317
|
_createValidationError(actualValue) {
|
|
2493
3318
|
return {
|
|
2494
3319
|
mask: {
|
|
@@ -2555,8 +3380,8 @@ class NgxMaskDirective {
|
|
|
2555
3380
|
return uniqueCharacters.size === 1;
|
|
2556
3381
|
});
|
|
2557
3382
|
}
|
|
2558
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
2559
|
-
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: [
|
|
2560
3385
|
{
|
|
2561
3386
|
provide: NG_VALUE_ACCESSOR,
|
|
2562
3387
|
useExisting: NgxMaskDirective,
|
|
@@ -2570,7 +3395,7 @@ class NgxMaskDirective {
|
|
|
2570
3395
|
NgxMaskService,
|
|
2571
3396
|
], exportAs: ["mask", "ngxMask"], usesOnChanges: true, ngImport: i0 });
|
|
2572
3397
|
}
|
|
2573
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
3398
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskDirective, decorators: [{
|
|
2574
3399
|
type: Directive,
|
|
2575
3400
|
args: [{
|
|
2576
3401
|
selector: 'input[mask], textarea[mask]',
|
|
@@ -2590,7 +3415,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImpor
|
|
|
2590
3415
|
],
|
|
2591
3416
|
exportAs: 'mask,ngxMask',
|
|
2592
3417
|
}]
|
|
2593
|
-
}], 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: [{
|
|
2594
3419
|
type: HostListener,
|
|
2595
3420
|
args: ['paste']
|
|
2596
3421
|
}], onFocus: [{
|
|
@@ -2703,10 +3528,10 @@ class NgxMaskPipe {
|
|
|
2703
3528
|
});
|
|
2704
3529
|
}
|
|
2705
3530
|
}
|
|
2706
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
2707
|
-
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" });
|
|
2708
3533
|
}
|
|
2709
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
3534
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: NgxMaskPipe, decorators: [{
|
|
2710
3535
|
type: Pipe,
|
|
2711
3536
|
args: [{
|
|
2712
3537
|
name: 'mask',
|