Skip to content

Instantly share code, notes, and snippets.

@Jeffen
Created October 16, 2018 20:10
Show Gist options
  • Save Jeffen/34d8b64c86839dbbe01059f16e92c8ca to your computer and use it in GitHub Desktop.
Save Jeffen/34d8b64c86839dbbe01059f16e92c8ca to your computer and use it in GitHub Desktop.
A mask input component for Angular material. Define your input mask with regex such as telephone number, email postal code etc.
<input type="text" [formControl]="_control" (keypress)="onKeyPress($event)" (blur)="onInputBlur($event)" (keydown)="onKeyDown($event)"
(input)="handleInputChange($event)" (paste)="handleInputChange($event)" (focus)="onInputFocus($event)" [readonly]="readonly"
[attr.required]="required" [attr.placeholder]="placeholder" />
input {
border: none;
background: none;
padding: 0;
outline: none;
font: inherit;
}
import {
Component,
OnDestroy,
ElementRef,
Input,
HostBinding,
forwardRef,
ChangeDetectionStrategy
} from '@angular/core';
import { Subject } from 'rxjs';
import { FormControl, NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
import { FocusMonitor } from '@angular/cdk/a11y';
import { MatFormFieldControl } from '@angular/material';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
/*
In order to use validators other than 'required', you need to include your validator in parent FormGroup
e.g. pattern, minLength and custom validators
*/
@Component({
selector: 'mat-mask-input',
templateUrl: './mat-mask-input.component.html',
styleUrls: ['./mat-mask-input.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: MatFormFieldControl, useExisting: MatMaskInput },
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MatMaskInput),
multi: true
}
]
})
export class MatMaskInput implements OnDestroy, MatFormFieldControl<string>, ControlValueAccessor {
static nextId = 0;
_mask: string;
defs: any;
tests: any[];
partialPosition: any;
len: number;
firstNonMaskPos: number;
lastRequiredNonMaskPos: any;
buffer: any;
defaultBuffer: string;
oldVal: string;
focusText: string;
inputRef: any;
caretTimeoutId: any;
focused = false;
stateChanges = new Subject<void>();
ngControl = null;
errorState = false;
controlType = 'mat-mask-input';
_control: FormControl;
@HostBinding('id')
id = `mat-mask-input-${MatMaskInput.nextId++}`;
@HostBinding('class.filled')
filled: boolean;
@HostBinding('attr.aria-describedby')
describedBy = '';
@HostBinding('class.floating')
get shouldLabelFloat() {
return this.focused || !this.empty;
}
@Input()
slotChar: string = '_';
@Input()
characterPattern: string = '[A-Za-z]';
@Input()
autoClear: boolean = true;
@Input()
unmask: boolean;
get empty() {
return !this._control.value;
}
@Input()
get placeholder(): string {
return this._placeholder;
}
set placeholder(value: string) {
this._placeholder = value;
this.stateChanges.next();
}
private _placeholder: string;
@Input()
get required(): boolean {
return this._required;
}
set required(value: boolean) {
this._required = coerceBooleanProperty(value);
this.stateChanges.next();
}
private _required = false;
@Input()
get readonly(): boolean {
return this._readonly;
}
set readonly(value: boolean) {
this._readonly = coerceBooleanProperty(value);
this.stateChanges.next();
}
private _readonly = false;
@Input()
get disabled(): boolean {
return this._disabled;
}
set disabled(value: boolean) {
this._disabled = coerceBooleanProperty(value);
if (this._disabled) {
this._control.disable();
} else {
this._control.enable();
}
this.stateChanges.next();
}
private _disabled = false;
@Input()
get value(): string | '' {
return this._control.value || this.inputRef.value || '';
}
set value(val: string | '') {
this._control.setValue(val);
this.inputRef.value = val;
this.stateChanges.next();
}
@Input()
get mask(): string {
return this._mask;
}
set mask(val: string) {
this._mask = val;
this.initMask();
this.writeValue('');
this.onModelChange(this.value);
}
onModelChange: Function = () => {};
onModelTouched: Function = () => {};
constructor(private fm: FocusMonitor, private elRef: ElementRef) {
this._control = new FormControl();
fm.monitor(elRef.nativeElement, true).subscribe(origin => {
this.focused = !!origin;
this.stateChanges.next();
});
}
ngOnDestroy() {
this.stateChanges.complete();
this.fm.stopMonitoring(this.elRef.nativeElement);
}
initMask() {
this.tests = [];
this.partialPosition = this.mask.length;
this.len = this.mask.length;
// this._control.setValidators(Validators.minLength(this.len));
this.firstNonMaskPos = null;
this.defs = {
'9': '[0-9]',
a: this.characterPattern,
'*': `${this.characterPattern}|[0-9]`
};
this.inputRef = this.elRef.nativeElement.querySelector('input');
const maskTokens = this.mask.split('');
for (let i = 0; i < maskTokens.length; i++) {
const c = maskTokens[i];
if (c === '?') {
this.len--;
this.partialPosition = i;
} else if (this.defs[c]) {
this.tests.push(new RegExp(this.defs[c]));
if (this.firstNonMaskPos === null) {
this.firstNonMaskPos = this.tests.length - 1;
}
if (i < this.partialPosition) {
this.lastRequiredNonMaskPos = this.tests.length - 1;
}
} else {
this.tests.push(null);
}
}
this.buffer = [];
for (let i = 0; i < maskTokens.length; i++) {
const c = maskTokens[i];
if (c !== '?') {
if (this.defs[c]) {
this.buffer.push(this.getPlaceholder(i));
} else {
this.buffer.push(c);
}
}
}
this.defaultBuffer = this.buffer.join('');
}
handleInputChange(event) {
if (this.readonly) {
return;
}
setTimeout(() => {
const pos = this.checkVal(true);
this.caret(pos);
this.updateModel(event);
}, 0);
}
checkVal(allow?: boolean) {
// try to place characters where they belong
const test = this.value;
let lastMatch = -1,
i,
c,
pos;
for (i = 0, pos = 0; i < this.len; i++) {
if (this.tests[i]) {
this.buffer[i] = this.getPlaceholder(i);
while (pos++ < test.length) {
c = test.charAt(pos - 1);
if (this.tests[i].test(c)) {
this.buffer[i] = c;
lastMatch = i;
break;
}
}
if (pos > test.length) {
this.clearBuffer(i + 1, this.len);
break;
}
} else {
if (this.buffer[i] === test.charAt(pos)) {
pos++;
}
if (i < this.partialPosition) {
lastMatch = i;
}
}
}
if (allow) {
this.writeBuffer();
} else if (lastMatch + 1 < this.partialPosition) {
if (this.autoClear || this.buffer.join('') === this.defaultBuffer) {
// Invalid value. Remove it and replace it with the
// mask, which is the default behavior.
if (this.value) {
this.value = '';
}
this.clearBuffer(0, this.len);
} else {
// Invalid value, but we opt to show the value to the
// user and allow them to correct their mistake.
this.writeBuffer();
}
} else {
this.writeBuffer();
this.value = this.value.substring(0, lastMatch + 1);
}
return this.partialPosition ? i : this.firstNonMaskPos;
}
caret(first?: number, last?: number) {
let range, begin, end;
if (!this.inputRef.offsetParent || this.inputRef !== document.activeElement) {
return;
}
if (typeof first === 'number') {
begin = first;
end = typeof last === 'number' ? last : begin;
if (this.inputRef.setSelectionRange) {
this.inputRef.setSelectionRange(begin, end);
} else if (this.inputRef['createTextRange']) {
range = this.inputRef['createTextRange']();
range.collapse(true);
range.moveEnd('character', end);
range.moveStart('character', begin);
range.select();
}
} else {
if (this.inputRef.setSelectionRange) {
begin = this.inputRef.selectionStart;
end = this.inputRef.selectionEnd;
} else if (document['selection'] && document['selection'].createRange) {
range = document['selection'].createRange();
begin = 0 - range.duplicate().moveStart('character', -100000);
end = begin + range.text.length;
}
return { begin: begin, end: end };
}
}
onKeyDown(e) {
if (this.readonly) {
return;
}
const k = e.which || e.keyCode;
let pos, begin, end;
this.oldVal = this.inputRef.value;
// backspace, delete, and escape get special treatment
if (k === 8 || k === 46) {
pos = this.caret();
begin = pos.begin;
end = pos.end;
if (end - begin === 0) {
begin = k !== 46 ? this.seekPrev(begin) : (end = this.seekNext(begin - 1));
end = k === 46 ? this.seekNext(end) : end;
}
this.clearBuffer(begin, end);
this.shiftL(begin, end - 1);
this.updateModel(e);
e.preventDefault();
} else if (k === 13) {
// enter
this.onInputBlur(e);
this.updateModel(e);
} else if (k === 27) {
// escape
this.value = this.focusText;
this.caret(0, this.checkVal());
this.updateModel(e);
e.preventDefault();
}
}
onKeyPress(e) {
if (this.readonly) {
return;
}
const k = e.which || e.keyCode,
pos = this.caret();
let p, c, next, completed;
if (e.ctrlKey || e.altKey || e.metaKey || k < 32 || (k > 34 && k < 41)) {
// Ignore
return;
} else if (k && k !== 13) {
if (pos.end - pos.begin !== 0) {
this.clearBuffer(pos.begin, pos.end);
this.shiftL(pos.begin, pos.end - 1);
}
p = this.seekNext(pos.begin - 1);
if (p < this.len) {
c = String.fromCharCode(k);
if (this.tests[p].test(c)) {
this.shiftR(p);
this.buffer[p] = c;
this.writeBuffer();
next = this.seekNext(p);
this.caret(next);
if (pos.begin <= this.lastRequiredNonMaskPos) {
completed = this.isCompleted();
}
}
}
e.preventDefault();
}
this.updateModel(e);
}
onInputFocus(event) {
if (this.readonly) {
return;
}
this.focused = true;
clearTimeout(this.caretTimeoutId);
let pos;
this.focusText = this.inputRef.value;
pos = this.checkVal();
this.caretTimeoutId = setTimeout(() => {
if (this.inputRef !== document.activeElement) {
return;
}
this.writeBuffer();
if (pos === this.mask.replace('?', '').length) {
this.caret(0, pos);
} else {
this.caret(pos);
}
}, 10);
}
onInputBlur(e) {
this.focused = false;
this.onModelTouched();
this.checkVal();
if (!this.isCompleted()) {
this.onModelChange('');
}
this.stateChanges.next();
}
isCompleted(): boolean {
for (let i = this.firstNonMaskPos; i <= this.lastRequiredNonMaskPos; i++) {
if (this.tests[i] && this.buffer[i] === this.getPlaceholder(i)) {
return false;
}
}
return true;
}
seekNext(pos) {
while (++pos < this.len && !this.tests[pos]) {}
return pos;
}
seekPrev(pos) {
while (--pos >= 0 && !this.tests[pos]) {}
return pos;
}
shiftL(begin: number, end: number) {
let i, j;
if (begin < 0) {
return;
}
for (i = begin, j = this.seekNext(end); i < this.len; i++) {
if (this.tests[i]) {
if (j < this.len && this.tests[i].test(this.buffer[j])) {
this.buffer[i] = this.buffer[j];
this.buffer[j] = this.getPlaceholder(j);
} else {
break;
}
j = this.seekNext(j);
}
}
this.writeBuffer();
this.caret(Math.max(this.firstNonMaskPos, begin));
}
shiftR(pos) {
let i, c, j, t;
for (i = pos, c = this.getPlaceholder(pos); i < this.len; i++) {
if (this.tests[i]) {
j = this.seekNext(i);
t = this.buffer[i];
this.buffer[i] = c;
if (j < this.len && this.tests[j].test(t)) {
c = t;
} else {
break;
}
}
}
}
clearBuffer(start, end) {
let i;
for (i = start; i < end && i < this.len; i++) {
if (this.tests[i]) {
this.buffer[i] = this.getPlaceholder(i);
}
}
}
writeBuffer() {
this.value = this.buffer.join('');
this.stateChanges.next();
}
getPlaceholder(i: number) {
if (i < this.slotChar.length) {
return this.slotChar.charAt(i);
}
return this.slotChar.charAt(0);
}
getUnmaskedValue() {
const unmaskedBuffer = [];
for (let i = 0; i < this.buffer.length; i++) {
const c = this.buffer[i];
if (this.tests[i] && c !== this.getPlaceholder(i)) {
unmaskedBuffer.push(c);
}
}
return unmaskedBuffer.join('');
}
updateModel(e) {
const updatedValue = this.unmask ? this.getUnmaskedValue() : e.target.value;
if (updatedValue !== null || updatedValue !== undefined) {
this.value = updatedValue;
this.onModelChange(this.value);
this.errorState = this._control.touched && !this.isCompleted();
this.stateChanges.next();
}
}
writeValue(value: any): void {
this.value = value;
this.checkVal();
this.focusText = value;
}
registerOnChange(fn: Function): void {
this.onModelChange = fn;
}
registerOnTouched(fn: Function): void {
this.onModelTouched = fn;
}
setDescribedByIds(ids: string[]) {
this.describedBy = ids.join(' ');
}
onContainerClick(event: MouseEvent) {
if (this._readonly || this._disabled) {
return;
}
if ((event.target as Element).tagName.toLowerCase() !== 'input') {
this.inputRef.focus();
}
}
}
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { MatMaskInput } from './mat-mask-input.component';
@NgModule({
imports: [CommonModule, ReactiveFormsModule],
declarations: [MatMaskInput],
entryComponents: [MatMaskInput],
exports: [MatMaskInput]
})
export class MatMaskInputModule {}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment