Created
October 16, 2018 20:10
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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" /> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
input { | |
border: none; | |
background: none; | |
padding: 0; | |
outline: none; | |
font: inherit; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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