Skip to content

feat(mask): Escape mask pattern literals #12658

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 10, 2023
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ All notable changes for each version of this project will be documented in this
- **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`.
- `IgxRadio`, `IgxRadioGroup`
- Added component validation along with styles for invalid state
- `igxMask` directive
- Added the capability to escape mask pattern literals.
- `IgxAvatar`
- **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 either `square` or `rounded`. The default shape of the avatar is `square`.


## 15.0.1

- `IgxGrid`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { Injectable } from '@angular/core';

/** @hidden */
export const MASK_FLAGS = ['C', '&', 'a', 'A', '?', 'L', '9', '0', '#'];

const FLAGS = new Set('aACL09#&?');
const REGEX = new Map([
['C', /(?!^$)/u], // Non-empty
['&', /[^\p{Separator}]/u], // Whitespace
['a', /[\p{Letter}\d\p{Separator}]/u], // Alphanumeric & whitespace
['A', /[\p{Letter}\d]/u], // Alphanumeric
['?', /[\p{Letter}\p{Separator}]/u], // Alpha & whitespace
['L', /\p{Letter}/u], // Alpha
['0', /\d/], // Numeric
['9', /[\d\p{Separator}]/u], // Numeric & whitespace
['#', /[\d\-+]/], // Numeric and sign
]);

/** @hidden */
export interface MaskOptions {
Expand All @@ -15,16 +26,47 @@ export interface Replaced {
end: number;
}

interface ParsedMask {
literals: Map<number, string>,
mask: string
};

const replaceCharAt = (string: string, idx: number, char: string) =>
`${string.substring(0, idx)}${char}${string.substring(idx + 1)}`;


export function parseMask(format: string): ParsedMask {
const literals = new Map<number, string>();
let mask = format;

for (let i = 0, j = 0; i < format.length; i++, j++) {
const [current, next] = [format.charAt(i), format.charAt(i + 1)];

if (current === '\\' && FLAGS.has(next)) {
mask = replaceCharAt(mask, j, '');
literals.set(j, next);
i++;
} else {
if (!FLAGS.has(current)) {
literals.set(j, current);
}
}
}

return { literals, mask };
}

/** @hidden */
@Injectable({
providedIn: 'root'
})
export class MaskParsingService {


public applyMask(inputVal: string, maskOptions: MaskOptions, pos: number = 0): string {
let outputVal = '';
let value = '';
const mask: string = maskOptions.format;
const literals: Map<number, string> = this.getMaskLiterals(mask);
const { literals, mask } = parseMask(maskOptions.format);
const literalKeys: number[] = Array.from(literals.keys());
const nonLiteralIndices: number[] = this.getNonLiteralIndices(mask, literalKeys);
const literalValues: string[] = Array.from(literals.values());
Expand Down Expand Up @@ -70,8 +112,7 @@ export class MaskParsingService {

public parseValueFromMask(maskedValue: string, maskOptions: MaskOptions): string {
let outputVal = '';
const mask: string = maskOptions.format;
const literals: Map<number, string> = this.getMaskLiterals(mask);
const literals: Map<number, string> = this.getMaskLiterals(maskOptions.format);
const literalValues: string[] = Array.from(literals.values());

for (const val of maskedValue) {
Expand All @@ -86,7 +127,8 @@ export class MaskParsingService {
}

public replaceInMask(maskedValue: string, value: string, maskOptions: MaskOptions, start: number, end: number): Replaced {
const literalsPositions: number[] = Array.from(this.getMaskLiterals(maskOptions.format).keys());
const { literals, mask } = parseMask(maskOptions.format);
const literalsPositions = Array.from(literals.keys());
value = this.replaceIMENumbers(value);
const chars = Array.from(value);
let cursor = start;
Expand All @@ -102,7 +144,7 @@ export class MaskParsingService {
continue;
}
if (chars[0]
&& !this.validateCharOnPosition(chars[0], i, maskOptions.format)
&& !this.validateCharOnPosition(chars[0], i, mask)
&& chars[0] !== maskOptions.promptChar) {
break;
}
Expand Down Expand Up @@ -142,73 +184,16 @@ export class MaskParsingService {
}

public getMaskLiterals(mask: string): Map<number, string> {
const literals = new Map<number, string>();
return parseMask(mask).literals;

for (let i = 0; i < mask.length; i++) {
const char = mask.charAt(i);
if (MASK_FLAGS.indexOf(char) === -1) {
literals.set(i, char);
}
}

return literals;
}

/** Validates only non literal positions. */
private validateCharOnPosition(inputChar: string, position: number, mask: string): boolean {
let regex: RegExp;
let isValid: boolean;
const letterOrDigitRegEx = '[\\d\\u00C0-\\u1FFF\\u2C00-\\uD7FFa-zA-Z]';
const letterDigitOrSpaceRegEx = '[\\d\\u00C0-\\u1FFF\\u2C00-\\uD7FFa-zA-Z\\u0020]';
const letterRegEx = '[\\u00C0-\\u1FFF\\u2C00-\\uD7FFa-zA-Z]';
const letterSpaceRegEx = '[\\u00C0-\\u1FFF\\u2C00-\\uD7FFa-zA-Z\\u0020]';
const digitRegEx = '[\\d]';
const digitSpaceRegEx = '[\\d\\u0020]';
const digitSpecialRegEx = '[\\d-\\+]';

switch (mask.charAt(position)) {
case 'C':
isValid = inputChar !== '';
break;
case '&':
regex = new RegExp('[\\u0020]');
isValid = !regex.test(inputChar);
break;
case 'a':
regex = new RegExp(letterDigitOrSpaceRegEx);
isValid = regex.test(inputChar);
break;
case 'A':
regex = new RegExp(letterOrDigitRegEx);
isValid = regex.test(inputChar);
break;
case '?':
regex = new RegExp(letterSpaceRegEx);
isValid = regex.test(inputChar);
break;
case 'L':
regex = new RegExp(letterRegEx);
isValid = regex.test(inputChar);
break;
case '0':
regex = new RegExp(digitRegEx);
isValid = regex.test(inputChar);
break;
case '9':
regex = new RegExp(digitSpaceRegEx);
isValid = regex.test(inputChar);
break;
case '#':
regex = new RegExp(digitSpecialRegEx);
isValid = regex.test(inputChar);
break;
default: {
isValid = null;
}
}

return isValid;
const regex = REGEX.get(mask.charAt(position));
return regex ? regex.test(inputChar) : false;
}

private getNonLiteralIndices(mask: string, literalKeys: number[]): number[] {
const nonLiteralsIndices: number[] = new Array();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ describe('igxMask', () => {
expect(input.nativeElement.value).toEqual('@#$YUA123_');
});

it('Initialize an input with escaped mask', () => {
const fixture = TestBed.createComponent(DefMaskComponent);
fixture.detectChanges();

const { input, maskDirective } = fixture.componentInstance;
;
maskDirective.mask = '+\\9 000 000';
fixture.detectChanges();

input.nativeElement.dispatchEvent(new Event('focus'));
fixture.detectChanges();

expect(input.nativeElement.value).toEqual('+9 ___ ___');
})

it('Mask rules - digit (0-9) or a space', fakeAsync(() => {
const fixture = TestBed.createComponent(DigitSpaceMaskComponent);
fixture.detectChanges();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
Input, NgModule, OnInit, AfterViewChecked,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MaskParsingService, MaskOptions } from './mask-parsing.service';
import { MaskParsingService, MaskOptions, parseMask } from './mask-parsing.service';
import { IBaseEventArgs, PlatformUtil } from '../../core/utils';
import { noop } from 'rxjs';

Expand Down Expand Up @@ -377,7 +377,7 @@ export class IgxMaskDirective implements OnInit, AfterViewChecked, ControlValueA
protected setPlaceholder(value: string): void {
const placeholder = this.nativeElement.placeholder;
if (!placeholder || placeholder === this.mask) {
this.renderer.setAttribute(this.nativeElement, 'placeholder', value || this.defaultMask);
this.renderer.setAttribute(this.nativeElement, 'placeholder', parseMask(value ?? '').mask || this.defaultMask);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/app/mask/mask.sample.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ <h4>Personal Data</h4>
<label igxLabel for="socialSecurityNumber">Social Security Number</label>
</igx-input-group>
<igx-input-group>
<input igxInput #phone name="phone" type="text" [igxMask]="'(####) 00-00-00 Ext. 9999'"
<input igxInput #phone name="phone" type="text" [igxMask]="'(+35\\9) 00-00-00 Ext. 9999'"
[igxTextSelection]="true"
[(ngModel)]="person.phone"/>
<label igxLabel for="phone">Phone</label>
Expand Down