import { Directive, ElementRef, EventEmitter, HostListener, Output, PipeTransform, Renderer2, Input, OnInit, AfterViewChecked, } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MaskParsingService, MaskOptions, parseMask } from './mask-parsing.service'; import { IBaseEventArgs, PlatformUtil } from '../../core/utils'; import { noop } from 'rxjs'; @Directive({ providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: IgxMaskDirective, multi: true }], selector: '[igxMask]', exportAs: 'igxMask', standalone: true }) export class IgxMaskDirective implements OnInit, AfterViewChecked, ControlValueAccessor { /** * Sets the input mask. * ```html * * ``` */ @Input('igxMask') public get mask(): string { return this._mask || this.defaultMask; } public set mask(val: string) { // B.P. 9th June 2021 #7490 if (val !== this._mask) { const cleanInputValue = this.maskParser.parseValueFromMask(this.inputValue, this.maskOptions); this.setPlaceholder(val); this._mask = val; this.updateInputValue(cleanInputValue); } } /** * Sets the character representing a fillable spot in the input mask. * Default value is "'_'". * ```html * * ``` */ @Input() public promptChar = '_'; /** * Specifies if the bound value includes the formatting symbols. * ```html * * ``` */ @Input() public includeLiterals: boolean; /** * Specifies a pipe to be used on blur. * ```html * * ``` */ @Input() public displayValuePipe: PipeTransform; /** * Specifies a pipe to be used on focus. * ```html * * ``` */ @Input() public focusedValuePipe: PipeTransform; /** * Emits an event each time the value changes. * Provides `rawValue: string` and `formattedValue: string` as event arguments. * ```html * * ``` */ @Output() public valueChanged = new EventEmitter(); /** @hidden */ public get nativeElement(): HTMLInputElement { return this.elementRef.nativeElement; } /** @hidden @internal; */ protected get inputValue(): string { return this.nativeElement.value; } /** @hidden @internal */ protected set inputValue(val: string) { this.nativeElement.value = val; } /** @hidden */ protected get maskOptions(): MaskOptions { const format = this.mask || this.defaultMask; const promptChar = this.promptChar && this.promptChar.substring(0, 1); return { format, promptChar }; } /** @hidden */ protected get selectionStart(): number { // Edge(classic) and FF don't select text on drop return this.nativeElement.selectionStart === this.nativeElement.selectionEnd && this._hasDropAction ? this.nativeElement.selectionEnd - this._droppedData.length : this.nativeElement.selectionStart; } /** @hidden */ protected get selectionEnd(): number { return this.nativeElement.selectionEnd; } /** @hidden */ protected get start(): number { return this._start; } /** @hidden */ protected get end(): number { return this._end; } protected _composing: boolean; protected _compositionStartIndex: number; private _compositionValue: string; private _end = 0; private _start = 0; private _key: string; private _mask: string; private _oldText = ''; private _dataValue = ''; private _focused = false; private _droppedData: string; private _hasDropAction: boolean; private readonly defaultMask = 'CCCCCCCCCC'; private _onTouchedCallback: () => void = noop; private _onChangeCallback: (_: any) => void = noop; constructor( protected elementRef: ElementRef, protected maskParser: MaskParsingService, protected renderer: Renderer2, protected platform: PlatformUtil) { } /** @hidden */ @HostListener('keydown', ['$event']) public onKeyDown(event: KeyboardEvent): void { const key = event.key; if (!key) { return; } if ((event.ctrlKey && (key === this.platform.KEYMAP.Z || key === this.platform.KEYMAP.Y))) { event.preventDefault(); } this._key = key; this._start = this.selectionStart; this._end = this.selectionEnd; } /** @hidden @internal */ @HostListener('compositionstart') public onCompositionStart(): void { if (!this._composing) { this._compositionStartIndex = this._start; this._composing = true; } } /** @hidden @internal */ @HostListener('compositionend') public onCompositionEnd(): void { this._start = this._compositionStartIndex; const end = this.selectionEnd; const valueToParse = this.inputValue.substring(this._start, end); this.updateInput(valueToParse); this._end = this.selectionEnd; this._compositionValue = this.inputValue; } /** @hidden @internal */ @HostListener('input', ['$event']) public onInputChanged(event): void { /** * '!this._focused' is a fix for #8165 * On page load IE triggers input events before focus events and * it does so for every single input on the page. * The mask needs to be prevented from doing anything while this is happening because * the end user will be unable to blur the input. * https://stackoverflow.com/questions/21406138/input-event-triggered-on-internet-explorer-when-placeholder-changed */ if (this._composing) { if (this.inputValue.length < this._oldText.length) { // software keyboard input delete this._key = this.platform.KEYMAP.BACKSPACE; } return; } // After the compositionend event Chromium triggers input events of type 'deleteContentBackward' and // we need to adjust the start and end indexes to include mask literals if (event.inputType === 'deleteContentBackward' && this._key !== this.platform.KEYMAP.BACKSPACE) { const isInputComplete = this._compositionStartIndex === 0 && this._end === this.mask.length; let numberOfMaskLiterals = 0; const literalPos = parseMask(this.maskOptions.format).literals.keys(); for (const index of literalPos) { if (index >= this._compositionStartIndex && index <= this._end) { numberOfMaskLiterals++; } } this.inputValue = isInputComplete ? this.inputValue.substring(0, this.selectionEnd - numberOfMaskLiterals) + this.inputValue.substring(this.selectionEnd) : this._compositionValue?.substring(0, this._compositionStartIndex) || this.inputValue; if (this._compositionValue) { this._start = this.selectionStart; this._end = this.selectionEnd; this.nativeElement.selectionStart = isInputComplete ? this._start - numberOfMaskLiterals : this._compositionStartIndex; this.nativeElement.selectionEnd = this._end - numberOfMaskLiterals; this.nativeElement.selectionEnd = this._end; this._start = this.selectionStart; this._end = this.selectionEnd; } } if (this._hasDropAction) { this._start = this.selectionStart; } let valueToParse = ''; switch (this._key) { case this.platform.KEYMAP.DELETE: this._end = this._start === this._end ? ++this._end : this._end; break; case this.platform.KEYMAP.BACKSPACE: this._start = this.selectionStart; break; default: valueToParse = this.inputValue.substring(this._start, this.selectionEnd); break; } this.updateInput(valueToParse); } /** @hidden */ @HostListener('paste') public onPaste(): void { this._oldText = this.inputValue; this._start = this.selectionStart; } /** @hidden */ @HostListener('focus') public onFocus(): void { if (this.nativeElement.readOnly) { return; } this._focused = true; this.showMask(this.inputValue); } /** @hidden */ @HostListener('blur', ['$event.target.value']) public onBlur(value: string): void { this._focused = false; this.showDisplayValue(value); this._onTouchedCallback(); } /** @hidden */ @HostListener('dragenter') public onDragEnter(): void { if (!this._focused && !this._dataValue) { this.showMask(this._dataValue); } } /** @hidden */ @HostListener('dragleave') public onDragLeave(): void { if (!this._focused) { this.showDisplayValue(this.inputValue); } } /** @hidden */ @HostListener('drop', ['$event']) public onDrop(event: DragEvent): void { this._hasDropAction = true; this._droppedData = event.dataTransfer.getData('text'); } /** @hidden */ public ngOnInit(): void { this.setPlaceholder(this.maskOptions.format); } /** * TODO: Remove after date/time picker integration refactor * * @hidden */ public ngAfterViewChecked(): void { if (this._composing) { return; } this._oldText = this.inputValue; } /** @hidden */ public writeValue(value: string): void { if (this.promptChar && this.promptChar.length > 1) { this.maskOptions.promptChar = this.promptChar.substring(0, 1); } this.inputValue = value ? this.maskParser.applyMask(value, this.maskOptions) : ''; if (this.displayValuePipe) { this.inputValue = this.displayValuePipe.transform(this.inputValue); } this._dataValue = this.includeLiterals ? this.inputValue : value; this.valueChanged.emit({ rawValue: value, formattedValue: this.inputValue }); } /** @hidden */ public registerOnChange(fn: (_: any) => void): void { this._onChangeCallback = fn; } /** @hidden */ public registerOnTouched(fn: () => void): void { this._onTouchedCallback = fn; } /** @hidden */ protected showMask(value: string): void { if (this.focusedValuePipe) { // TODO(D.P.): focusedValuePipe should be deprecated or force-checked to match mask format this.inputValue = this.focusedValuePipe.transform(value); } else { this.inputValue = this.maskParser.applyMask(value, this.maskOptions); } this._oldText = this.inputValue; } /** @hidden */ protected setSelectionRange(start: number, end: number = start): void { this.nativeElement.setSelectionRange(start, end); } /** @hidden */ protected afterInput(): void { this._oldText = this.inputValue; this._hasDropAction = false; this._start = 0; this._end = 0; this._key = null; this._composing = false; } /** @hidden */ protected setPlaceholder(value: string): void { const placeholder = this.nativeElement.placeholder; if (!placeholder || placeholder === this.mask) { this.renderer.setAttribute(this.nativeElement, 'placeholder', parseMask(value ?? '').mask || this.defaultMask); } } private updateInputValue(value: string) { if (this._focused) { this.showMask(value); } else if (!this.displayValuePipe) { this.inputValue = this.inputValue ? this.maskParser.applyMask(value, this.maskOptions) : ''; } } private updateInput(valueToParse: string) { const replacedData = this.maskParser.replaceInMask(this._oldText, valueToParse, this.maskOptions, this._start, this._end); this.inputValue = replacedData.value; if (this._key === this.platform.KEYMAP.BACKSPACE) { replacedData.end = this._start; } this.setSelectionRange(replacedData.end); const rawVal = this.maskParser.parseValueFromMask(this.inputValue, this.maskOptions); this._dataValue = this.includeLiterals ? this.inputValue : rawVal; this._onChangeCallback(this._dataValue); this.valueChanged.emit({ rawValue: rawVal, formattedValue: this.inputValue }); this.afterInput(); } private showDisplayValue(value: string) { if (this.displayValuePipe) { this.inputValue = this.displayValuePipe.transform(value); } else if (value === this.maskParser.applyMask(null, this.maskOptions)) { this.inputValue = ''; } } } /** * The IgxMaskModule provides the {@link IgxMaskDirective} inside your application. */ export interface IMaskEventArgs extends IBaseEventArgs { rawValue: string; formattedValue: string; } /** @hidden */