<!DOCTYPE html>
<html>
<head>
<title>Masked Input Control</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/zone.js@0.6.21/dist/zone.js"></script>
<script src="https://unpkg.com/reflect-metadata@0.1.9/Reflect.js"></script>
<script src="https://unpkg.com/systemjs@0.19.41/dist/system.js"></script>
<script src="https://unpkg.com/typescript@2.1.4/lib/typescript.js"></script>
<script src="config.js"></script>
<script>
System.import('app')
.catch(console.error.bind(console));
</script>
</head>
<body>
<my-app>
loading...
</my-app>
</body>
</html>
System.config({
transpiler: 'typescript',
typescriptOptions: {
emitDecoratorMetadata: true,
experimentalDecorators: true
},
map: {
app: "./src",
'@angular/core': 'npm:@angular/core@4.0.0/bundles/core.umd.js',
'@angular/common': 'npm:@angular/common@4.0.0/bundles/common.umd.js',
'@angular/compiler': 'npm:@angular/compiler@4.0.0/bundles/compiler.umd.js',
'@angular/platform-browser': 'npm:@angular/platform-browser@4.0.0/bundles/platform-browser.umd.js',
'@angular/platform-browser-dynamic': 'npm:@angular/platform-browser-dynamic@4.0.0/bundles/platform-browser-dynamic.umd.js',
'@angular/http': 'npm:@angular/http@4.0.0/bundles/http.umd.js',
'@angular/router': 'npm:@angular/router@4.0.0/bundles/router.umd.js',
'@angular/forms': 'npm:@angular/forms@4.0.0/bundles/forms.umd.js',
'@angular/animations': 'npm:@angular/animations@4.0.0/bundles/animations.umd.js',
'@angular/core/testing': 'npm:@angular/core@4.0.0/bundles/core-testing.umd.js',
'@angular/common/testing': 'npm:@angular/common/bundles@4.0.0/common-testing.umd.js',
'@angular/compiler/testing': 'npm:@angular/compiler@4.0.0/bundles/compiler-testing.umd.js',
'@angular/platform-browser/testing': 'npm:@angular/platform-browser@4.0.0/bundles/platform-browser-testing.umd.js',
'@angular/platform-browser-dynamic/testing': 'npm:@angular/platform-browser-dynamic@4.0.0/bundles/platform-browser-dynamic-testing.umd.js',
'@angular/http/testing': 'npm:@angular/http@4.0.0/bundles/http-testing.umd.js',
'@angular/router/testing': 'npm:@angular/router@4.0.0/bundles/router-testing.umd.js',
'rxjs': 'npm:rxjs@5.1.0',
'lodash': 'npm:lodash@4.17.4/'
},
//packages defines our app package
packages: {
app: {
main: './main.ts',
defaultExtension: 'ts'
},
rxjs: {
defaultExtension: 'js'
},
lodash: {
defaultExtension: 'js'
}
},
paths: {
'npm:': 'https://unpkg.com/'
}
});
//main entry point
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app';
platformBrowserDynamic().bootstrapModule(AppModule)
import { Component, NgModule, OnInit } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule, FormBuilder, ReactiveFormsModule, FormGroup, Validators } from '@angular/forms';
import { MaskedInputComponent } from './md-input.component.ts';
@Component({
selector: 'my-app',
template: `
<form [formGroup]="cardForm" (ngSubmit)="submitState(localState.value)" autocomplete="off">
<md-input
[title]="'Card Number'"
formControlName="card"
[mask]="cardMask"></md-input>
<button class="btn">
<span>Submit</span>
</button>
</form>
<span *ngIf="cardForm.valid">Credit card number is valid</span>
<pre>Card control value: {{cardForm.value.card}}</pre>
`
})
export class App implements OnInit {
public cardForm: FormGroup;
public cardMask = [/\d/, /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/];
constructor(private _formBuilder: FormBuilder) {
}
public ngOnInit() {
this.cardForm = this._formBuilder.group({
card: ['', Validators.pattern(/^[0-9]{16}$/)]
});
this.cardForm.controls.card.setValue('1234567890123456');
}
public submitState(value: string) {
console.log('submitState', value);
}
}
@NgModule({
imports: [ BrowserModule, FormsModule, ReactiveFormsModule ],
declarations: [ App, MaskedInputComponent ],
bootstrap: [ App ]
})
export class AppModule {}
import {Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild, ViewEncapsulation} from "@angular/core";
import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR} from "@angular/forms";
@Component({
selector: 'md-input',
template: `<div class="group">
<input #mdInputEl class="spacer"
[formControl]="mdInput"/>
<span class="highlight"></span>
<span class="bar"></span>
<label>{{title}}</label>
</div>`,
encapsulation: ViewEncapsulation.None,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MaskedInputComponent),
multi: true,
}
]
})
export class MaskedInputComponent implements ControlValueAccessor, OnInit {
@ViewChild('mdInputEl') public mdInputEl: ElementRef;
@Input() mask: any[];
@Input() title: string;
public mdInput = new FormControl();
private _previousValue: string = '';
private _previousPlaceholder: string = '';
private _maxInputValue: number;
private _currentCursorPosition: number;
private readonly _placeholderChar: string = '_';
public ngOnInit(): void {
this._maxInputValue = this.mask.length;
this.mdInput.valueChanges
.subscribe((value: string) => {
if (!value || value === this._previousValue) {
return;
}
this._currentCursorPosition = this.mdInputEl.nativeElement.selectionEnd;
const placeholder = this._convertMaskToPlaceholder();
const values = this._conformValue(value, placeholder);
const adjustedCursorPosition = this._getCursorPosition(value, placeholder, values.conformed);
this.mdInputEl.nativeElement.value = values.conformed;
this.mdInputEl.nativeElement.setSelectionRange(
adjustedCursorPosition,
adjustedCursorPosition,
'none');
this._onChange(values.cleaned);
this._previousValue = values.conformed;
this._previousPlaceholder = placeholder;
},
(err) => console.warn(err)
);
}
public writeValue(value: string): void {
this._currentCursorPosition = this.mdInputEl.nativeElement.selectionEnd;
const placeholder = this._convertMaskToPlaceholder();
let values = this._conformValue(value, placeholder);
this.mdInputEl.nativeElement.value = values.conformed;
this.mdInput.setValue(values.conformed);
}
public registerOnChange(fn: any): void {
this._onChange = fn;
}
public registerOnTouched(fn: any): void {
this._onTouched = fn;
}
private _onChange: Function = (_: any) => {
}
private _onTouched: Function = (_: any) => {
}
private _convertMaskToPlaceholder(): string {
return this.mask.map((char) => {
return (char instanceof RegExp) ? this._placeholderChar : char;
}).join('');
}
private _conformValue(value: string, placeholder: string): { conformed: string, cleaned: string } {
const editDistance = value.length - this._previousValue.length;
const isAddition = editDistance > 0;
const indexOfFirstChange = this._currentCursorPosition + (isAddition ? -editDistance : 0);
const indexOfLastChange = indexOfFirstChange + Math.abs(editDistance);
if (!isAddition) {
let compensatingPlaceholderChars = '';
for (let i = indexOfFirstChange; i < indexOfLastChange; i++) {
if (placeholder[i] === this._placeholderChar) {
compensatingPlaceholderChars += this._placeholderChar;
}
}
value =
(value.slice(0, indexOfFirstChange) +
compensatingPlaceholderChars +
value.slice(indexOfFirstChange, value.length)
);
}
const valueArr = value.split('');
for (let i = value.length - 1; i >= 0; i--) {
let char = value[i];
if (char !== this._placeholderChar) {
const shouldOffset = i >= indexOfFirstChange &&
this._previousValue.length === this._maxInputValue;
if (char === placeholder[(shouldOffset) ? i - editDistance : i]) {
valueArr.splice(i, 1);
}
}
}
let conformedValue = '';
let cleanedValue = '';
placeholderLoop: for (let i = 0; i < placeholder.length; i++) {
const charInPlaceholder = placeholder[i];
if (charInPlaceholder === this._placeholderChar) {
if (valueArr.length > 0) {
while (valueArr.length > 0) {
let valueChar = valueArr.shift();
if (valueChar === this._placeholderChar) {
conformedValue += this._placeholderChar;
continue placeholderLoop;
} else if (this.mask[i].test(valueChar)) {
conformedValue += valueChar;
cleanedValue += valueChar;
continue placeholderLoop;
}
}
}
conformedValue += placeholder.substr(i, placeholder.length);
break;
} else {
conformedValue += charInPlaceholder;
}
}
return {conformed: conformedValue, cleaned: cleanedValue};
}
private _getCursorPosition(value: string, placeholder: string, conformedValue: string): number {
if (this._currentCursorPosition === 0) {
return 0;
}
const editLength = value.length - this._previousValue.length;
const isAddition = editLength > 0;
const isFirstValue = this._previousValue.length === 0;
const isPartialMultiCharEdit = editLength > 1 && !isAddition && !isFirstValue;
if (isPartialMultiCharEdit) {
return this._currentCursorPosition;
}
const possiblyHasRejectedChar = isAddition && (
this._previousValue === conformedValue ||
conformedValue === placeholder);
let startingSearchIndex = 0;
let trackRightCharacter;
let targetChar;
if (possiblyHasRejectedChar) {
startingSearchIndex = this._currentCursorPosition - editLength;
} else {
const normalizedConformedValue = conformedValue.toLowerCase();
const normalizedValue = value.toLowerCase();
const leftHalfChars = normalizedValue.substr(0, this._currentCursorPosition).split('');
const intersection = leftHalfChars.filter((char) => normalizedConformedValue.indexOf(char) !== -1);
targetChar = intersection[intersection.length - 1];
const previousLeftMaskChars = this._previousPlaceholder
.substr(0, intersection.length)
.split('')
.filter((char) => char !== this._placeholderChar)
.length;
const leftMaskChars = placeholder
.substr(0, intersection.length)
.split('')
.filter((char) => char !== this._placeholderChar)
.length;
const maskLengthChanged = leftMaskChars !== previousLeftMaskChars;
const targetIsMaskMovingLeft = (
this._previousPlaceholder[intersection.length - 1] !== undefined &&
placeholder[intersection.length - 2] !== undefined &&
this._previousPlaceholder[intersection.length - 1] !== this._placeholderChar &&
this._previousPlaceholder[intersection.length - 1] !== placeholder[intersection.length - 1] &&
this._previousPlaceholder[intersection.length - 1] === placeholder[intersection.length - 2]
);
if (!isAddition &&
(maskLengthChanged || targetIsMaskMovingLeft) &&
previousLeftMaskChars > 0 &&
placeholder.indexOf(targetChar) > -1 &&
value[this._currentCursorPosition] !== undefined) {
trackRightCharacter = true;
targetChar = value[this._currentCursorPosition];
}
const countTargetCharInIntersection = intersection.filter((char) => char === targetChar).length;
const countTargetCharInPlaceholder = placeholder
.substr(0, placeholder.indexOf(this._placeholderChar))
.split('')
.filter((char, index) => (
char === targetChar &&
value[index] !== char
)).length;
const requiredNumberOfMatches =
(countTargetCharInPlaceholder + countTargetCharInIntersection + (trackRightCharacter ? 1 : 0));
let numberOfEncounteredMatches = 0;
for (let i = 0; i < conformedValue.length; i++) {
const conformedValueChar = normalizedConformedValue[i];
startingSearchIndex = i + 1;
if (conformedValueChar === targetChar) {
numberOfEncounteredMatches++;
}
if (numberOfEncounteredMatches >= requiredNumberOfMatches) {
break;
}
}
}
if (isAddition) {
let lastPlaceholderChar = startingSearchIndex;
for (let i = startingSearchIndex; i <= placeholder.length; i++) {
if (placeholder[i] === this._placeholderChar) {
lastPlaceholderChar = i;
}
if (placeholder[i] === this._placeholderChar || i === placeholder.length) {
return lastPlaceholderChar;
}
}
} else {
if (trackRightCharacter) {
for (let i = startingSearchIndex - 1; i >= 0; i--) {
if (
conformedValue[i] === targetChar ||
i === 0
) {
return i;
}
}
} else {
for (let i = startingSearchIndex; i >= 0; i--) {
if (placeholder[i - 1] === this._placeholderChar || i === 0) {
return i;
}
}
}
}
}
}