Skip to content

Commit fba2569

Browse files
authored
Merge pull request #12658 from IgniteUI/rkaraivanov/issue-7089
feat(mask): Escape mask pattern literals
2 parents 03788aa + 8feaf7b commit fba2569

File tree

5 files changed

+114
-104
lines changed

5 files changed

+114
-104
lines changed

Diff for: CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ All notable changes for each version of this project will be documented in this
2323
- **Breaking Change** The `onSlideChanged`, `onSlideAdded`, `onSlideRemoved`, `onCarouselPaused` and `onCarouselPlaying` outputs have been renamed to `slideChanged`, `slideAdded`, `slideRemoved`, `carouselPaused` and `carouselPlaying` to not violate the no on-prefixed outputs convention. Automatic migrations are available and will be applied on `ng update`.
2424
- `IgxRadio`, `IgxRadioGroup`
2525
- Added component validation along with styles for invalid state
26+
- `igxMask` directive
27+
- Added the capability to escape mask pattern literals.
2628
- `IgxBadge`
2729
- Added `shape` property that controls the shape of the badge and can be either `square` or `rounded`. The default shape of the badge is rounded.
2830
- `IgxAvatar`
2931
- **Breaking Change** The `roundShape` property has been deprecated and will be removed in a future version. Users can control the shape of the avatar by the newly added `shape` attribute that can be `square`, `rounded` or `circle`. The default shape of the avatar is `square`.
3032

33+
3134
## 15.0.1
3235

3336
- `IgxGrid`

Diff for: projects/igniteui-angular/src/lib/directives/mask/mask-parsing.service.ts

+56-84
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { Injectable } from '@angular/core';
22

3-
/** @hidden */
4-
export const MASK_FLAGS = ['C', '&', 'a', 'A', '?', 'L', '9', '0', '#'];
3+
4+
const FLAGS = new Set('aACL09#&?');
5+
const REGEX = new Map([
6+
['C', /(?!^$)/u], // Non-empty
7+
['&', /[^\p{Separator}]/u], // Non-whitespace
8+
['a', /[\p{Letter}\d\p{Separator}]/u], // Alphanumeric & whitespace
9+
['A', /[\p{Letter}\d]/u], // Alphanumeric
10+
['?', /[\p{Letter}\p{Separator}]/u], // Alpha & whitespace
11+
['L', /\p{Letter}/u], // Alpha
12+
['0', /\d/], // Numeric
13+
['9', /[\d\p{Separator}]/u], // Numeric & whitespace
14+
['#', /[\d\-+]/], // Numeric and sign
15+
]);
516

617
/** @hidden */
718
export interface MaskOptions {
@@ -15,16 +26,46 @@ export interface Replaced {
1526
end: number;
1627
}
1728

29+
interface ParsedMask {
30+
literals: Map<number, string>,
31+
mask: string
32+
};
33+
34+
const replaceCharAt = (string: string, idx: number, char: string) =>
35+
`${string.substring(0, idx)}${char}${string.substring(idx + 1)}`;
36+
37+
38+
export function parseMask(format: string): ParsedMask {
39+
const literals = new Map<number, string>();
40+
let mask = format;
41+
42+
for (let i = 0, j = 0; i < format.length; i++, j++) {
43+
const [current, next] = [format.charAt(i), format.charAt(i + 1)];
44+
45+
if (current === '\\' && FLAGS.has(next)) {
46+
mask = replaceCharAt(mask, j, '');
47+
literals.set(j, next);
48+
i++;
49+
} else {
50+
if (!FLAGS.has(current)) {
51+
literals.set(j, current);
52+
}
53+
}
54+
}
55+
56+
return { literals, mask };
57+
}
58+
1859
/** @hidden */
1960
@Injectable({
2061
providedIn: 'root'
2162
})
2263
export class MaskParsingService {
64+
2365
public applyMask(inputVal: string, maskOptions: MaskOptions, pos: number = 0): string {
2466
let outputVal = '';
2567
let value = '';
26-
const mask: string = maskOptions.format;
27-
const literals: Map<number, string> = this.getMaskLiterals(mask);
68+
const { literals, mask } = parseMask(maskOptions.format);
2869
const literalKeys: number[] = Array.from(literals.keys());
2970
const nonLiteralIndices: number[] = this.getNonLiteralIndices(mask, literalKeys);
3071
const literalValues: string[] = Array.from(literals.values());
@@ -38,7 +79,7 @@ export class MaskParsingService {
3879
}
3980

4081
literals.forEach((val: string, key: number) => {
41-
outputVal = this.replaceCharAt(outputVal, key, val);
82+
outputVal = replaceCharAt(outputVal, key, val);
4283
});
4384

4485
if (!value) {
@@ -62,17 +103,15 @@ export class MaskParsingService {
62103

63104
for (const nonLiteralValue of nonLiteralValues) {
64105
const char = nonLiteralValue;
65-
outputVal = this.replaceCharAt(outputVal, nonLiteralIndices[pos++], char);
106+
outputVal = replaceCharAt(outputVal, nonLiteralIndices[pos++], char);
66107
}
67108

68109
return outputVal;
69110
}
70111

71112
public parseValueFromMask(maskedValue: string, maskOptions: MaskOptions): string {
72113
let outputVal = '';
73-
const mask: string = maskOptions.format;
74-
const literals: Map<number, string> = this.getMaskLiterals(mask);
75-
const literalValues: string[] = Array.from(literals.values());
114+
const literalValues: string[] = Array.from(parseMask(maskOptions.format).literals.values());
76115

77116
for (const val of maskedValue) {
78117
if (literalValues.indexOf(val) === -1) {
@@ -86,7 +125,8 @@ export class MaskParsingService {
86125
}
87126

88127
public replaceInMask(maskedValue: string, value: string, maskOptions: MaskOptions, start: number, end: number): Replaced {
89-
const literalsPositions: number[] = Array.from(this.getMaskLiterals(maskOptions.format).keys());
128+
const { literals, mask } = parseMask(maskOptions.format);
129+
const literalsPositions = Array.from(literals.keys());
90130
value = this.replaceIMENumbers(value);
91131
const chars = Array.from(value);
92132
let cursor = start;
@@ -102,7 +142,7 @@ export class MaskParsingService {
102142
continue;
103143
}
104144
if (chars[0]
105-
&& !this.validateCharOnPosition(chars[0], i, maskOptions.format)
145+
&& !this.validateCharOnPosition(chars[0], i, mask)
106146
&& chars[0] !== maskOptions.promptChar) {
107147
break;
108148
}
@@ -112,7 +152,7 @@ export class MaskParsingService {
112152
cursor = i + 1;
113153
char = chars.shift();
114154
}
115-
maskedValue = this.replaceCharAt(maskedValue, i, char);
155+
maskedValue = replaceCharAt(maskedValue, i, char);
116156
}
117157

118158
if (value.length <= 1) {
@@ -132,83 +172,15 @@ export class MaskParsingService {
132172
}
133173
}
134174

135-
return { value: maskedValue, end: cursor};
136-
}
137-
138-
public replaceCharAt(strValue: string, index: number, char: string): string {
139-
if (strValue !== undefined) {
140-
return strValue.substring(0, index) + char + strValue.substring(index + 1);
141-
}
142-
}
143-
144-
public getMaskLiterals(mask: string): Map<number, string> {
145-
const literals = new Map<number, string>();
146-
147-
for (let i = 0; i < mask.length; i++) {
148-
const char = mask.charAt(i);
149-
if (MASK_FLAGS.indexOf(char) === -1) {
150-
literals.set(i, char);
151-
}
152-
}
153-
154-
return literals;
175+
return { value: maskedValue, end: cursor };
155176
}
156177

157178
/** Validates only non literal positions. */
158179
private validateCharOnPosition(inputChar: string, position: number, mask: string): boolean {
159-
let regex: RegExp;
160-
let isValid: boolean;
161-
const letterOrDigitRegEx = '[\\d\\u00C0-\\u1FFF\\u2C00-\\uD7FFa-zA-Z]';
162-
const letterDigitOrSpaceRegEx = '[\\d\\u00C0-\\u1FFF\\u2C00-\\uD7FFa-zA-Z\\u0020]';
163-
const letterRegEx = '[\\u00C0-\\u1FFF\\u2C00-\\uD7FFa-zA-Z]';
164-
const letterSpaceRegEx = '[\\u00C0-\\u1FFF\\u2C00-\\uD7FFa-zA-Z\\u0020]';
165-
const digitRegEx = '[\\d]';
166-
const digitSpaceRegEx = '[\\d\\u0020]';
167-
const digitSpecialRegEx = '[\\d-\\+]';
168-
169-
switch (mask.charAt(position)) {
170-
case 'C':
171-
isValid = inputChar !== '';
172-
break;
173-
case '&':
174-
regex = new RegExp('[\\u0020]');
175-
isValid = !regex.test(inputChar);
176-
break;
177-
case 'a':
178-
regex = new RegExp(letterDigitOrSpaceRegEx);
179-
isValid = regex.test(inputChar);
180-
break;
181-
case 'A':
182-
regex = new RegExp(letterOrDigitRegEx);
183-
isValid = regex.test(inputChar);
184-
break;
185-
case '?':
186-
regex = new RegExp(letterSpaceRegEx);
187-
isValid = regex.test(inputChar);
188-
break;
189-
case 'L':
190-
regex = new RegExp(letterRegEx);
191-
isValid = regex.test(inputChar);
192-
break;
193-
case '0':
194-
regex = new RegExp(digitRegEx);
195-
isValid = regex.test(inputChar);
196-
break;
197-
case '9':
198-
regex = new RegExp(digitSpaceRegEx);
199-
isValid = regex.test(inputChar);
200-
break;
201-
case '#':
202-
regex = new RegExp(digitSpecialRegEx);
203-
isValid = regex.test(inputChar);
204-
break;
205-
default: {
206-
isValid = null;
207-
}
208-
}
209-
210-
return isValid;
180+
const regex = REGEX.get(mask.charAt(position));
181+
return regex ? regex.test(inputChar) : false;
211182
}
183+
212184
private getNonLiteralIndices(mask: string, literalKeys: number[]): number[] {
213185
const nonLiteralsIndices: number[] = new Array();
214186

Diff for: projects/igniteui-angular/src/lib/directives/mask/mask.directive.spec.ts

+35
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,41 @@ describe('igxMask', () => {
5858
expect(input.nativeElement.value).toEqual('@#$YUA123_');
5959
});
6060

61+
it('Initialize an input with escaped mask', () => {
62+
const fixture = TestBed.createComponent(DefMaskComponent);
63+
fixture.detectChanges();
64+
65+
const { input, maskDirective } = fixture.componentInstance;
66+
67+
maskDirective.mask = '+\\9 000 000';
68+
fixture.detectChanges();
69+
70+
input.nativeElement.dispatchEvent(new Event('focus'));
71+
fixture.detectChanges();
72+
73+
expect(input.nativeElement.value).toEqual('+9 ___ ___');
74+
});
75+
76+
it('Escaped mask - advanced escaped patterns with input', () => {
77+
const fixture = TestBed.createComponent(DefMaskComponent);
78+
fixture.detectChanges();
79+
80+
const { input, maskDirective } = fixture.componentInstance;
81+
maskDirective.mask = '\\C\\C CCCC - \\0\\00 - X\\9\\9';
82+
fixture.detectChanges();
83+
84+
input.nativeElement.dispatchEvent(new Event('focus'));
85+
fixture.detectChanges();
86+
87+
expect(input.nativeElement.value).toEqual('CC ____ - 00_ - X99');
88+
89+
input.nativeElement.value = 'abcdefgh';
90+
input.nativeElement.dispatchEvent(new Event('input'));
91+
fixture.detectChanges();
92+
93+
expect(input.nativeElement.value).toEqual('CC abcd - 00_ - X99');
94+
});
95+
6196
it('Mask rules - digit (0-9) or a space', fakeAsync(() => {
6297
const fixture = TestBed.createComponent(DigitSpaceMaskComponent);
6398
fixture.detectChanges();

Diff for: projects/igniteui-angular/src/lib/directives/mask/mask.directive.ts

+19-19
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
Input, NgModule, OnInit, AfterViewChecked,
66
} from '@angular/core';
77
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
8-
import { MaskParsingService, MaskOptions } from './mask-parsing.service';
8+
import { MaskParsingService, MaskOptions, parseMask } from './mask-parsing.service';
99
import { IBaseEventArgs, PlatformUtil } from '../../core/utils';
1010
import { noop } from 'rxjs';
1111

@@ -212,27 +212,27 @@ export class IgxMaskDirective implements OnInit, AfterViewChecked, ControlValueA
212212
// After the compositionend event Chromium triggers input events of type 'deleteContentBackward' and
213213
// we need to adjust the start and end indexes to include mask literals
214214
if (event.inputType === 'deleteContentBackward' && this._key !== this.platform.KEYMAP.BACKSPACE) {
215-
const isInputComplete = this._compositionStartIndex === 0 && this._end === this.mask.length;
216-
let numberOfMaskLiterals = 0;
217-
const literalPos = this.maskParser.getMaskLiterals(this.maskOptions.format).keys();
218-
for (const index of literalPos) {
219-
if (index >= this._compositionStartIndex && index <= this._end) {
220-
numberOfMaskLiterals++;
221-
}
215+
const isInputComplete = this._compositionStartIndex === 0 && this._end === this.mask.length;
216+
let numberOfMaskLiterals = 0;
217+
const literalPos = parseMask(this.maskOptions.format).literals.keys();
218+
for (const index of literalPos) {
219+
if (index >= this._compositionStartIndex && index <= this._end) {
220+
numberOfMaskLiterals++;
222221
}
223-
this.inputValue = isInputComplete ?
222+
}
223+
this.inputValue = isInputComplete ?
224224
this.inputValue.substring(0, this.selectionEnd - numberOfMaskLiterals) + this.inputValue.substring(this.selectionEnd)
225225
: this._compositionValue?.substring(0, this._compositionStartIndex) || this.inputValue;
226226

227-
if (this._compositionValue) {
228-
this._start = this.selectionStart;
229-
this._end = this.selectionEnd;
230-
this.nativeElement.selectionStart = isInputComplete ? this._start - numberOfMaskLiterals : this._compositionStartIndex;
231-
this.nativeElement.selectionEnd = this._end - numberOfMaskLiterals;
232-
this.nativeElement.selectionEnd = this._end;
233-
this._start = this.selectionStart;
234-
this._end = this.selectionEnd;
235-
}
227+
if (this._compositionValue) {
228+
this._start = this.selectionStart;
229+
this._end = this.selectionEnd;
230+
this.nativeElement.selectionStart = isInputComplete ? this._start - numberOfMaskLiterals : this._compositionStartIndex;
231+
this.nativeElement.selectionEnd = this._end - numberOfMaskLiterals;
232+
this.nativeElement.selectionEnd = this._end;
233+
this._start = this.selectionStart;
234+
this._end = this.selectionEnd;
235+
}
236236
}
237237

238238
if (this._hasDropAction) {
@@ -377,7 +377,7 @@ export class IgxMaskDirective implements OnInit, AfterViewChecked, ControlValueA
377377
protected setPlaceholder(value: string): void {
378378
const placeholder = this.nativeElement.placeholder;
379379
if (!placeholder || placeholder === this.mask) {
380-
this.renderer.setAttribute(this.nativeElement, 'placeholder', value || this.defaultMask);
380+
this.renderer.setAttribute(this.nativeElement, 'placeholder', parseMask(value ?? '').mask || this.defaultMask);
381381
}
382382
}
383383

Diff for: src/app/mask/mask.sample.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ <h4>Personal Data</h4>
2020
<label igxLabel for="socialSecurityNumber">Social Security Number</label>
2121
</igx-input-group>
2222
<igx-input-group>
23-
<input igxInput #phone name="phone" type="text" [igxMask]="'(####) 00-00-00 Ext. 9999'"
23+
<input igxInput #phone name="phone" type="text" [igxMask]="'(+35\\9) 00-00-00 Ext. 9999'"
2424
[igxTextSelection]="true"
2525
[(ngModel)]="person.phone"/>
2626
<label igxLabel for="phone">Phone</label>

0 commit comments

Comments
 (0)