Mask for an Input

2019-01-03 14:23发布

Is it possible to have model-driven form in Angular 2 and implement a directive that would allow to mask an input field like a phone number entry (123) 123-4567?

8条回答
Root(大扎)
2楼-- · 2019-01-03 15:04

Combining Günter Zöchbauer's answer with good-old vanilla-JS, here is a directive with two lines of logic that supports (123) 456-7890 format.

Reactive Forms: Plunk

import { Directive, Output, EventEmitter } from "@angular/core";
import { NgControl } from "@angular/forms";

@Directive({
  selector: '[formControlName][phone]',
  host: {
    '(ngModelChange)': 'onInputChange($event)'
  }
})
export class PhoneMaskDirective {

  @Output() rawChange:EventEmitter<string> = new EventEmitter<string>();

  constructor(public model: NgControl) {}

  onInputChange(value) {
        var x = value.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/);
        var y = !x[2] ? x[1] : '(' + x[1] + ') ' + x[2] + (x[3] ? '-' + x[3] : '');

        this.model.valueAccessor.writeValue(y);
        this.rawChange.emit(rawValue); 
  }
}

Template-driven Forms: Plunk

import { Directive } from "@angular/core";
import { NgControl } from "@angular/forms";

@Directive({
  selector: '[ngModel][phone]',
  host: {
    '(ngModelChange)': 'onInputChange($event)'
  }
})
export class PhoneMaskDirective {

  constructor(public model: NgControl) {}

  onInputChange(value) {
        var x = value.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/);
        value = !x[2] ? x[1] : '(' + x[1] + ') ' + x[2] + (x[3] ? '-' + x[3] : '');

        this.model.valueAccessor.writeValue(value);       
  }
}
查看更多
姐就是有狂的资本
3楼-- · 2019-01-03 15:05

Angular5 and 6:

angular 5 and 6 recommended way is to use @HostBindings and @HostListeners instead of the host property

remove host and add @HostListener

 @HostListener('ngModelChange', ['$event'])
  onModelChange(event) {
    this.onInputChange(event, false);
  }

  @HostListener('keydown.backspace', ['$event'])
  keydownBackspace(event) {
    this.onInputChange(event.target.value, true);
  }

Working Online stackblitz Link: https://angular6-phone-mask.stackblitz.io

Stackblitz Code example: https://stackblitz.com/edit/angular6-phone-mask

Official documentation link https://angular.io/guide/attribute-directives#respond-to-user-initiated-events

Angular2 and 4:

Plunker >= RC.5

original

One way you could do it is using a directive that injects NgControl and manipulates the value

(for details see inline comments)

@Directive({
  selector: '[ngModel][phone]',
  host: {
    '(ngModelChange)': 'onInputChange($event)',
    '(keydown.backspace)': 'onInputChange($event.target.value, true)'
  }
})
export class PhoneMask {
  constructor(public model: NgControl) {}

  onInputChange(event, backspace) {
    // remove all mask characters (keep only numeric)
    var newVal = event.replace(/\D/g, '');
    // special handling of backspace necessary otherwise
    // deleting of non-numeric characters is not recognized
    // this laves room for improvement for example if you delete in the 
    // middle of the string
    if (backspace) {
      newVal = newVal.substring(0, newVal.length - 1);
    } 

    // don't show braces for empty value
    if (newVal.length == 0) {
      newVal = '';
    } 
    // don't show braces for empty groups at the end
    else if (newVal.length <= 3) {
      newVal = newVal.replace(/^(\d{0,3})/, '($1)');
    } else if (newVal.length <= 6) {
      newVal = newVal.replace(/^(\d{0,3})(\d{0,3})/, '($1) ($2)');
    } else {
      newVal = newVal.replace(/^(\d{0,3})(\d{0,3})(.*)/, '($1) ($2)-$3');
    }
    // set the new value
    this.model.valueAccessor.writeValue(newVal);       
  }
}
@Component({
  selector: 'my-app',
  providers: [],
  template: `
  <form [ngFormModel]="form">
    <input type="text" phone [(ngModel)]="data" ngControl="phone"> 
  </form>
  `,
  directives: [PhoneMask]
})
export class App {
  constructor(fb: FormBuilder) {
    this.form = fb.group({
      phone: ['']
    })
  }
}

Plunker example <= RC.5

查看更多
聊天终结者
4楼-- · 2019-01-03 15:14

Angular 4+

I've created a generic directive, able to receive any mask and also able to define the mask dynamically based on the value:

mask.directive.ts:

import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core';
import { NgControl } from '@angular/forms';

import { MaskGenerator } from '../interfaces/mask-generator.interface';

@Directive({
    selector: '[spMask]' 
})
export class MaskDirective {

    private static readonly ALPHA = 'A';
    private static readonly NUMERIC = '9';
    private static readonly ALPHANUMERIC = '?';
    private static readonly REGEX_MAP = new Map([
        [MaskDirective.ALPHA, /\w/],
        [MaskDirective.NUMERIC, /\d/],
        [MaskDirective.ALPHANUMERIC, /\w|\d/],
    ]);

    private value: string = null;
    private displayValue: string = null;

    @Input('spMask') 
    public maskGenerator: MaskGenerator;

    @Input('spKeepMask') 
    public keepMask: boolean;

    @Input('spMaskValue') 
    public set maskValue(value: string) {
        if (value !== this.value) {
            this.value = value;
            this.defineValue();
        }
    };

    @Output('spMaskValueChange') 
    public changeEmitter = new EventEmitter<string>();

    @HostListener('input', ['$event'])
    public onInput(event: { target: { value?: string }}): void {
        let target = event.target;
        let value = target.value;
        this.onValueChange(value);
    }

    constructor(private ngControl: NgControl) { }

    private updateValue(value: string) {
        this.value = value;
        this.changeEmitter.emit(value);
        MaskDirective.delay().then(
            () => this.ngControl.control.updateValueAndValidity()
        );
    }

    private defineValue() {
        let value: string = this.value;
        let displayValue: string = null;

        if (this.maskGenerator) {
            let mask = this.maskGenerator.generateMask(value);

            if (value != null) {
                displayValue = MaskDirective.mask(value, mask);
                value = MaskDirective.processValue(displayValue, mask, this.keepMask);
            }   
        } else {
            displayValue = this.value;
        }

        MaskDirective.delay().then(() => {
            if (this.displayValue !== displayValue) {
                this.displayValue = displayValue;
                this.ngControl.control.setValue(displayValue);
                return MaskDirective.delay();
            }
        }).then(() => {
            if (value != this.value) {
                return this.updateValue(value);
            }
        });
    }

    private onValueChange(newValue: string) {
        if (newValue !== this.displayValue) {
            let displayValue = newValue;
            let value = newValue;

            if ((newValue == null) || (newValue.trim() === '')) {
                value = null;
            } else if (this.maskGenerator) {
                let mask = this.maskGenerator.generateMask(newValue);
                displayValue = MaskDirective.mask(newValue, mask);
                value = MaskDirective.processValue(displayValue, mask, this.keepMask);
            }

            this.displayValue = displayValue;

            if (newValue !== displayValue) {
                this.ngControl.control.setValue(displayValue);
            }

            if (value !== this.value) {
                this.updateValue(value);
            }
        }
    }

    private static processValue(displayValue: string, mask: string, keepMask: boolean) {
        let value = keepMask ? displayValue : MaskDirective.unmask(displayValue, mask);
        return value
    }

    private static mask(value: string, mask: string): string {
        value = value.toString();

        let len = value.length;
        let maskLen = mask.length;
        let pos = 0;
        let newValue = '';

        for (let i = 0; i < Math.min(len, maskLen); i++) {
            let maskChar = mask.charAt(i);
            let newChar = value.charAt(pos);
            let regex: RegExp = MaskDirective.REGEX_MAP.get(maskChar);

            if (regex) {
                pos++;

                if (regex.test(newChar)) {
                    newValue += newChar;
                } else {
                    i--;
                    len--;
                }
            } else {
                if (maskChar === newChar) {
                    pos++;
                } else {
                    len++;
                }

                newValue += maskChar;
            }
        }       

        return newValue;
    }

    private static unmask(maskedValue: string, mask: string): string {
        let maskLen = (mask && mask.length) || 0;
        return maskedValue.split('').filter(
            (currChar, idx) => (idx < maskLen) && MaskDirective.REGEX_MAP.has(mask[idx])
        ).join('');
    }

    private static delay(ms: number = 0): Promise<void> {
        return new Promise(resolve => setTimeout(() => resolve(), ms)).then(() => null);
    }
}

(Remember to declare it in your NgModule)

The numeric character in the mask is 9 so your mask would be (999) 999-9999. You can change the NUMERIC static field if you want (if you change it to 0, your mask should be (000) 000-0000, for example).

The value is displayed with mask but stored in the component field without mask (this is the desirable behaviour in my case). You can make it be stored with mask using [spKeepMask]="true".

The directive receives an object that implements the MaskGenerator interface.

mask-generator.interface.ts:

export interface MaskGenerator {
    generateMask: (value: string) => string;
}

This way it's possible to define the mask dynamically based on the value (like credit cards).

I've created an utilitarian class to store the masks, but you can specify it directly in your component too.

my-mask.util.ts:

export class MyMaskUtil {

    private static PHONE_SMALL = '(999) 999-9999';
    private static PHONE_BIG = '(999) 9999-9999';
    private static CPF = '999.999.999-99';
    private static CNPJ = '99.999.999/9999-99';

    public static PHONE_MASK_GENERATOR: MaskGenerator = {
        generateMask: () =>  MyMaskUtil.PHONE_SMALL,
    }

    public static DYNAMIC_PHONE_MASK_GENERATOR: MaskGenerator = {
        generateMask: (value: string) => {
            return MyMaskUtil.hasMoreDigits(value, MyMaskUtil.PHONE_SMALL) ? 
                MyMaskUtil.PHONE_BIG : 
                MyMaskUtil.PHONE_SMALL;
        },
    }

    public static CPF_MASK_GENERATOR: MaskGenerator = {
        generateMask: () => MyMaskUtil.CPF,
    }

    public static CNPJ_MASK_GENERATOR: MaskGenerator = {
        generateMask: () => MyMaskUtil.CNPJ,
    }

    public static PERSON_MASK_GENERATOR: MaskGenerator = {
        generateMask: (value: string) => {
            return MyMaskUtil.hasMoreDigits(value, MyMaskUtil.CPF) ? 
                MyMaskUtil.CNPJ : 
                MyMaskUtil.CPF;
        },
    }

    private static hasMoreDigits(v01: string, v02: string): boolean {
        let d01 = this.onlyDigits(v01);
        let d02 = this.onlyDigits(v02);
        let len01 = (d01 && d01.length) || 0;
        let len02 = (d02 && d02.length) || 0;
        let moreDigits = (len01 > len02);
        return moreDigits;      
    }

    private static onlyDigits(value: string): string {
        let onlyDigits = (value != null) ? value.replace(/\D/g, '') : null;
        return onlyDigits;      
    }
}

Then you can use it in your component (use spMaskValue instead of ngModel, but if is not a reactive form, use ngModel with nothing, like in the example below, just so that you won't receive an error of no provider because of the injected NgControl in the directive; in reactive forms you don't need to include ngModel):

my.component.ts:

@Component({ ... })
export class MyComponent {
    public phoneValue01: string = '1231234567';
    public phoneValue02: string;
    public phoneMask01 = MyMaskUtil.PHONE_MASK_GENERATOR;
    public phoneMask02 = MyMaskUtil.DYNAMIC_PHONE_MASK_GENERATOR;
}

my.component.html:

<span>Phone 01 ({{ phoneValue01 }}):</span><br>
<input type="text" [(spMaskValue)]="phoneValue01" [spMask]="phoneMask01" ngModel>
<br><br>
<span>Phone 02 ({{ phoneValue02 }}):</span><br>
<input type="text" [(spMaskValue)]="phoneValue02" [spMask]="phoneMask02" [spKeepMask]="true" ngModel>

(Take a look at phone02 and see that when you type 1 more digit, the mask changes; also, look that the value stored of phone01 is without mask)

I've tested it with normal inputs as well as with ionic inputs (ion-input), with both reactive (with formControlName, not with formControl) and non-reactive forms.

查看更多
劳资没心,怎么记你
5楼-- · 2019-01-03 15:18

Reactive Form


See at Stackblitz

Addition to the @Günter Zöchbauer's answer above, I tried as follows and it seems to be working but I'm not sure whether it is a efficient way.

I use valueChanges observable to listen for change events in the reactive form by subscribing to it. For special handling of backspace, I get the data from subscribe and check it with userForm.value.phone(from [formGroup]="userForm"). Because, at that moment, the data changes to the new value but the latter refers to the previous value because of not setting yet. If the data is less than previous value then the user should remove character from input. In this case, change pattern as follows:

from : newVal = newVal.replace(/^(\d{0,3})/, '($1)');

to : newVal = newVal.replace(/^(\d{0,3})/, '($1');

Otherwise, as Günter Zöchbauer mentioned above, deleting of non-numeric characters is not recognized because when we remove parentheses from input, digits still remain the same and added again parentheses from pattern match.

Controller:

import { Component,OnInit } from '@angular/core';
import { FormGroup,FormBuilder,AbstractControl,Validators } from '@angular/forms';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit{

  constructor(private fb:FormBuilder) { 
    this.createForm();
  }

  createForm(){
    this.userForm = this.fb.group({
      phone:['',[Validators.pattern(/^\(\d{3}\)\s\d{3}-\d{4}$/),Validators.required]],
    });
  }

  ngOnInit() {
   this.phoneValidate();
  }

  phoneValidate(){
    const phoneControl:AbstractControl = this.userForm.controls['phone'];

    phoneControl.valueChanges.subscribe(data => {
    /**the most of code from @Günter Zöchbauer's answer.*/

    /**we remove from input but: 
       @preInputValue still keep the previous value because of not setting.
    */
    let preInputValue:string = this.userForm.value.phone;
    let lastChar:string = preInputValue.substr(preInputValue.length - 1);

    var newVal = data.replace(/\D/g, '');
    //when removed value from input
    if (data.length < preInputValue.length) {

      /**while removing if we encounter ) character,
         then remove the last digit too.*/
      if(lastChar == ')'){
         newVal = newVal.substr(0,newVal.length-1); 
      }
      if (newVal.length == 0) {
        newVal = '';
      } 
      else if (newVal.length <= 3) {
        /**when removing, we change pattern match.
        "otherwise deleting of non-numeric characters is not recognized"*/
        newVal = newVal.replace(/^(\d{0,3})/, '($1');
      } else if (newVal.length <= 6) {
        newVal = newVal.replace(/^(\d{0,3})(\d{0,3})/, '($1) $2');
      } else {
        newVal = newVal.replace(/^(\d{0,3})(\d{0,3})(.*)/, '($1) $2-$3');
      }
    //when typed value in input
    } else{


    if (newVal.length == 0) {
      newVal = '';
    } 
    else if (newVal.length <= 3) {
      newVal = newVal.replace(/^(\d{0,3})/, '($1)');
    } else if (newVal.length <= 6) {
      newVal = newVal.replace(/^(\d{0,3})(\d{0,3})/, '($1) $2');
    } else {
      newVal = newVal.replace(/^(\d{0,3})(\d{0,3})(.*)/, '($1) $2-$3');
    }

  }
    this.userForm.controls['phone'].setValue(newVal,{emitEvent: false});
  });
 }

}

Template:

<form [formGroup]="userForm"  novalidate>
  <div class="form-group">
    <label for="tel">Tel:</label>
    <input id="tel" formControlName="phone" maxlength="14">
  </div>
  <button [disabled]="userForm.status == 'INVALID'" type="submit">Send</button>
</form>

UPDATE

Is there a way to preserve cursor position while backspacing in the middle of the string? Currently, it jumps back to the end.

Define an id <input id="tel" formControlName="phone" #phoneRef> and renderer2#selectRootElement to get the native element in the component.

So we can get the cursor position using:

let start = this.renderer.selectRootElement('#tel').selectionStart;
let end = this.renderer.selectRootElement('#tel').selectionEnd;

and then we can apply it after the input is updated to new value:

this.userForm.controls['phone'].setValue(newVal,{emitEvent: false});
//keep cursor the appropriate position after setting the input above.
this.renderer.selectRootElement('#tel').setSelectionRange(start,end);

UPDATE 2

It's probably better to put this sort of logic inside a directive rather than in the component

I also put the logic into a directive. This makes it easier to apply it to other elements.

See at Stackblitz

Note: It is specific to (123) 123-4567 pattern.

查看更多
来,给爷笑一个
6楼-- · 2019-01-03 15:19

It can be done using a directive. Below is the plunker of the input mask I built.

https://plnkr.co/edit/hRsmd0EKci6rjGmnYFRr?p=preview

Code:

import {Directive, Attribute, ElementRef, OnInit, OnChanges, Input, SimpleChange } from 'angular2/core';
import {NgControl, DefaultValueAccessor} from 'angular2/common';

@Directive({
selector: '[mask-input]',
host: {
    //'(keyup)': 'onInputChange()',
    '(click)': 'setInitialCaretPosition()'
}, 
inputs: ['modify'],
providers: [DefaultValueAccessor]
})
export class MaskDirective implements OnChanges {
maskPattern: string;
placeHolderCounts: any;
dividers: string[];
modelValue: string;
viewValue: string;
intialCaretPos: any;
numOfChar: any;
@Input() modify: any; 

constructor(public model: NgControl, public ele: ElementRef, @Attribute("mask-input") maskPattern: string) {
    this.dividers = maskPattern.replace(/\*/g, "").split("");
    this.dividers.push("_");
    this.generatePattern(maskPattern);   
    this.numOfChar = 0;
}

ngOnChanges(changes: { [propertyName: string]: SimpleChange }) {
    this.onInputChange(changes);
}

onInputChange(changes: { [propertyName: string]: SimpleChange }) {             
    this.modelValue = this.getModelValue();
    var caretPosition = this.ele.nativeElement.selectionStart;
    if (this.viewValue != null) {
      this.numOfChar = this.getNumberOfChar(caretPosition);
    }
    var stringToFormat = this.modelValue;        

    if (stringToFormat.length < 10) {
        stringToFormat = this.padString(stringToFormat);
    }

    this.viewValue = this.format(stringToFormat);

    if (this.viewValue != null) {
        caretPosition = this.setCaretPosition(this.numOfChar);
    }

    this.model.viewToModelUpdate(this.modelValue);
    this.model.valueAccessor.writeValue(this.viewValue);
    this.ele.nativeElement.selectionStart = caretPosition;
    this.ele.nativeElement.selectionEnd = caretPosition;
}

generatePattern(patternString) {
    this.placeHolderCounts = (patternString.match(/\*/g) || []).length;
    for (var i = 0; i < this.placeHolderCounts; i++) {
        patternString = patternString.replace('*', "{" + i + "}");
    }
    this.maskPattern = patternString;
}

format(s) {
    var formattedString = this.maskPattern;
    for (var i = 0; i < this.placeHolderCounts; i++) {
        formattedString = formattedString.replace("{" + i + "}", s.charAt(i));
    }
    return formattedString;
}

padString(s) {
    var pad = "__________";
    return (s + pad).substring(0, pad.length);
}

getModelValue() {
    var modelValue = this.model.value;
    if (modelValue == null) {
        return "";
    }
    for (var i = 0; i < this.dividers.length; i++) {
        while (modelValue.indexOf(this.dividers[i]) > -1) {
            modelValue = modelValue.replace(this.dividers[i], "");
        }
    }
    return modelValue;
}

setInitialCaretPosition() {
    var caretPosition = this.setCaretPosition(this.modelValue.length);

    this.ele.nativeElement.selectionStart = caretPosition;
    this.ele.nativeElement.selectionEnd = caretPosition;

}

setCaretPosition(num) {
    var notDivider = true;
    var caretPos = 1;
    for (; num > 0; caretPos++) {
      var ch = this.viewValue.charAt(caretPos);
      if (!this.isDivider(ch)) {
        num--;
      }
    }
    return caretPos;
}

isDivider(ch) {
    for (var i = 0; i < this.dividers.length; i++) {
          if (ch == this.dividers[i]) {
              return true;
          }
    }
}

getNumberOfChar(pos) {
  var num = 0;
  var containDividers = false;
  for (var i = 0; i < pos; i++) {
    var ch = this.modify.charAt(i);

    if (!this.isDivider(ch)) {
      num++;
    }
    else {
      containDividers = true;
    }
  }
  if (containDividers) {
    return num;
  }
  else {
    return this.numOfChar;
  }
}

}

Note: there are still a few bugs.

查看更多
啃猪蹄的小仙女
7楼-- · 2019-01-03 15:19

you can use cleave.js

// phone (123) 123-4567
var cleavePhone = new Cleave('.input-phone', {
        //prefix: '(123)',
        delimiters: ['(',') ','-'],
        blocks: [0, 3, 3, 4]
});

demo: https://jsfiddle.net/emirM/a8fogse1/

查看更多
登录 后发表回答