Простой Form Control с маской ввода

В этой статье рассмотрим как создать простую маску ввода, которая будет задаваться массивом RegExp и знаков разделителей. Решение не универсально, но вполне применимо в большинстве случаев. И если вы не хотите включать в зависимость тяжеловесные библиотеки с множеством лишних функций и сложной логикой, то это решение для вас.
ControlValueAccessor
Создание кастомного Form Control’а начинается с имплементации интерфейса ControlValueAccessor
.
ControlValueAccessor
устанавливает связь между контролом и нативным элементом.
Который содержит в себе 4 описания функций:
writeValue(obj: any): void;
– записывает новое значение в элементregisterOnChange(fn: any): void;
– устанавливает функцию которая будет вызвана после того, как контрол получит change событие.registerOnTouched(fn: any): void;
– устанавливает функцию которая вызывается после нажатия на контрол.setDisabledState?(isDisabled: boolean): void;
– эта функция не обязательна к имплементации. Она вызывается когда контрол получает состояния disabled. В теле функции описывается поведение в зависимости от статуса.
После имплементации они выглядят примерно так:
private _onChange: Function = (_: any) => { }
private _onTouched: Function = (_: any) => { }
public writeValue(value: string): void {
this.mdInput.setValue(value);
}
public registerOnChange(fn: any): void {
this._onChange = fn;
}
public registerOnTouched(fn: any): void {
this._onTouched = fn;
}
Описание компонента
Для того, чтобы закрепить ControlValueAccessor
за нашим котролом мы должны ‘информировать’ NG_VALUE_ACCESSOR
, который отвечает за биндинг данных.
@Component({
selector: 'md-input',
templateUrl: './md-input.component.html',
styleUrls: ['./md-input.component.scss'],
encapsulation: ViewEncapsulation.None,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MaskedInputComponent),
multi: true,
}
]
})
Далее начнем работать непосредственно над компонентом маски ввода. Создадим его шаблон.
<div class="group">
<input #mdInputEl class="spacer"
[formControl]="mdInput"/>
<span class="highlight"></span>
<span class="bar"></span>
<label></label>
</div>
Получаем доступ к нативному элементу
@ViewChild('mdInputEl') public mdInputEl: ElementRef;
и создаем FormControl, который мы заранее доабвили в шаблоне, путем присвоения атрибута [formControl]
у <input>
.
public mdInput = new FormControl();
Наблюдение и обработка новых значений
Для того, чтобы следить за изменениями значения в FormControl подпишемся на соответствующий Observable при инициализации компонента.
public ngOnInit(): void {
this.mdInput.valueChanges
.subscribe((value: string) => {
//
},
(err) => console.warn(err)
);
}
И добавим туда следующую логику:
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;
this.mdInputEl.nativeElement.selectionEnd
предназначен для получения/задания конечной позиции выделения, но в нашем случае это неплохой способ получить текущую позицию курсора.
Затем получаем шаблон для поля ввода, и делается это в соответствии с заданой маской.
Следующим шагом идет преобразование значения из поля по маске. Где на выходе два значения: преобразованое - шаблонизированное по маске, и чистое значение для отправки на сервер.
Далее получаем корректное положение курсора, в зависимости от произошедшего события (удаление, удаление из середины строки, добавление в начало строки и т.д.). И функцией setSelectionRange()
присваиваем значение позиции курсора.
Вызываем коллбек _onChange(value: any)
для обновления значения в модели.
И в конце сохраняем значения, которые небоходимы для обработки последующих изменений.
Теперь каждую функцию рассмотрим подробнее.
Шаблон ввода
Шаблон конвертируем из маски путем сопоставления ее элементов: если это RegExp то ставим спец.символ шаблона, в ином случае добавляем содержание этого элемента.
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};
}
Этот блок выполнится в случае удаления.
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)
);
}
На позиции удаленного символа подставляется знак маски.
Следующий цикл удаляет символы маски из ввода.
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);
}
}
}
К примеру до ввода маска zip кода была 00000 ____
и пользователь ввел еще одну цифру, то цикл удалит знак “_” и на этом месте окажется введнная цифра: 00000 1___
.
И вот он, “главный” цикл, который составляет значения шаблона в соответствии с маской.
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;
}
}
Основной принцип которого: i-й символ это символ шаблона или же это регулярка по маске и значение подходит под ее условие - добавляем к значению, в ином случае игнор.
Вычисление позиции курсора
После того когда получили преобразованные значения, нужно корректно установить курсор, с учетом различных действий пользователя.
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;
}
}
}
}
}
В целом все проверки имеют соответствующие названия и ясно их предназначение. Но на всякий случай объясню некоторые.
const possiblyHasRejectedChar = isAddition && (
this._previousValue === conformedValue ||
conformedValue === placeholder);
Эта проверка на случай если в шаблоне 111__ ____
с маской zip индекса (\d\d\d\d\d \d\d\d\d
) был введен символ 111r_ ____
, то значение не должно измениться и курсор остаться на своем месте.
Проверяем, символ маски ли это и есть ли смещение влево:
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]
);
Если произошло удаление и targetChar
это символ от шаблона, и также изменилась длина шаблона или маска сместилась влево, то отслеживаем на символ справа от курсора:
if (!isAddition &&
(maskLengthChanged || targetIsMaskMovingLeft) &&
previousLeftMaskChars > 0 &&
placeholder.indexOf(targetChar) > -1 &&
value[this._currentCursorPosition] !== undefined) {
trackRightCharacter = true;
targetChar = value[this._currentCursorPosition];
}
Далее подсчитываем сколько раз targetChar
встречается в пересечениях и в шаблоне.
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;
Следущим циклом ищем расположение targetChar
.
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;
}
}
После первого совпадения выходим из цикла. Если идет поиск второй единицы в 1234
, то startingSearchIndex
после выполнения будет иметь значение 4
.
Следующая логика выполняется при добавлении символов.
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;
}
}
}
Запоминается последний символ шаблона, и если маска содержит после него еще символы, то курсор не в право уже не должен перемещаться, а остановиться у последнего символа шаблона.
Следующая логика выполняется если не isAddition
, то есть в случае удаления.
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;
}
}
}
Ищем символ который стоял справа от курсора. Поиск начинается с startingSearchIndex - 1
потому, что в ином случае будет включен лишний символ справа.
Поиск перемещается влево, пока не будет найдено то место и тот символ. Затем будет выставлен курсор справа от удаленного символа.
На этом работы с положением курсора достаточно, основные случаи описаны.
Валидация
И создаем форму с валидатором.
this.cardForm = this._formBuilder.group({
card: ['', Validators.pattern(/^[0-9]{16}$/)]
});
this.cardForm.controls.card.setValue('1234567890123456');
Результат
Теперь контрол маски ввода готов к использованию.
Задается маска следующим образом:
public cardMask = [/\d/, /\d/, /\d/, /\d/, ' ', /\d/, /\d/, /\d/, /\d/, ' ', /\d/, /\d/, /\d/, /\d/, ' ', /\d/, /\d/, /\d/, /\d/];