Трудности маскирования текстового поля
Один античный оратор говорил, что всем людям свойственно ошибаться. Прошло много веков, а человек продолжает совершать ошибки каждый день. Даже беглое заполнение формы на сайте не обходится без опечаток.
Хороший UI/UX помогает пользователю избежать большинства таких проблем. Инструментов контроля огромное количество, сегодня расскажу про один их них — создание маски для поля ввода силами Javascript.
Да что такое, эта ваша маска
Представим ситуацию, что сайт хочет запросить данные, которые должны содержать только цифры. Например, для поля ввода цены товара мы не хотим разрешать пользователю вводить буквы и прочие символы.
Читатель может вспомнить, что в HTML уже есть . Открываем наш любимый Chrome, страницу с документацией элемента, пробуем ввести что-то кроме цифр и точки или запятой… ура! Браузер запрещает это сделать, и при попытке ввода невалидного символа значение инпута не изменяется. Кажется, что проблема решилась быстро, пора и статью завершать на этой удачной находке!
Но погодите. Давайте откроем Firefox и повторим те же действия. К несчастью, этот браузер менее строг к вводу невалидных символов и лишь при отправке формы начинает выдавать предупреждение:
То, что сделал Firefox в нашем примере, — это издевательство один из способов предупредить пользователя о невалидном значении. Но, кажется, не очень своевременный. А вот Chrome показал один из примеров маскирования инпута.
Маска — это контролирование вводимых пользователем символов, чтобы значение текстового поля соответствовало определенному правилу или паттерну.
Пример с вводом чисел — один вид маски из множества возможных. Существуют более сложные примеры: ввод времени, даты или телефонного номера.
Маска может не только предотвратить ввод невалидных значений, но и помочь пользователю добавить нужные символы. Например, поставить пробелы между тысячными разрядами числа или разделители между днем/месяцем/годом в дате.
Маска может даже угадывать намерения пользователя: подставлять в поле ввода точку в качестве разделителя целой и дробной части, если в поле ввода пользователь нажимает на клавиатуре букву «ю».
Маскирование инпута — это скорее про повышение UX, чем про валидацию данных. Опытный злоумышленник способен обмануть любое фронтовое веб-приложение. Поэтому дополнительная финальная валидация данных на беке всегда нужна!
Ингредиенты маски
Нужно разобраться в большом списке событий, которые возникают у элементов и
, чтобы контролировать вводимые значения в текстовом поле. Расскажу об основных.
Keydown — событие, которое возникает каждый раз при нажатии любой клавиши с клавиатуры. Оно содержит полезное свойство key
, в котором и хранится информация о введенном значении. И самое главное — событие можно отменить через event.preventDefault
!
Кажется, что такое событие идеально подходит для маскирования инпутов и полностью закрывает все необходимые задачи. Но есть два недостатка:
Существование системных клавиш создает ряд проблем в использовании события для нашей задачи. Например, пользователь будет копировать значение инпута через
Ctrl
+C
и получится «ложноположительное» для нашей задачи срабатываниеkeydown
. Требуется много усилий, чтобы отфильтровать нужные события для маски.Событие не может контролировать ввод значений через браузерный автофил и выбор предлагаемого значения с нативной клавиатуры мобильного устройства.
А еще для нашей задачи при использовании keydown
придется звать на помощь paste
и drop
события, которые обсудим чуть позже.
Keypress — событие, идентичное keydown
-событию, но с одним приятным исключением: оно срабатывает только при нажатии клавиш, порождающих новое значение в поле ввода. Keypress
не стреляет ненужным нам нажатием системных клавиш и полностью решает первый недостаток keydown
-события.
Но и в этом событии есть минус: на странице документации висит предупреждение, что свойство устарело и больше не поддерживается браузерами. Если зайти в современный браузер, видно, что свойство все еще существует и работает как задумано, но в любой момент его поддержка прекратится. Поэтому такое событие отбрасываем и больше не рассматриваем для использования.
Paste и Drop — события, про которые часто забывают. Пользователь может изменить значение текстового поля не только нажатием клавиш с клавиатуры, но и через вставку из буфера обмена и сбросив текст в инпут. Поэтому paste
и drop
нужно использовать, когда маскирование инпута происходит с обработкой keydown
.
Change — сообщает об изменении значения инпута. Но момент срабатывания события для текстовых инпутов происходят только после потери фокуса. То есть, если пользователь сфокусируется на инпуте и попытается напечатать слово «привет», у инпута сработает шесть событий keydown
и только одно change
— при условии, что пользователь все же уберет фокус с поля. Несмотря на многообещающее название и хорошую поддержку браузерами, это событие нам не подходит.
Input — полезное для нас событие, которое решает многие проблемы упомянутых ранее «коллег». Плюсы события:
Input
срабатывает после каждого изменения значения текстового инпута, не дожидается потери фокуса, какChange
, и не требует прочих условий.Нажатие системных клавиш не триггерит событие, если значение не меняется.
Контролирует все манипуляции с текстовым полем: событие сработает и при вставке значения из буфера обмена, и при браузерном автозаполнении, и при сбрасывании текста в инпут мышкой, и при выборе подсказок с нативной мобильной клавиатуры.
Хорошая поддержка всеми современными браузерами.
Ложка дегтя с Input
в том, что событие нельзя отменить свойством preventDefault
, потому что событие сообщает об уже случившимся факте. А прошлое изменить нельзя!
Можно запоминать значение поля и позицию его каретки до изменений и в случае отмены программно обновлять поле старым значением. Но все это приводит к не очень хорошей поддерживаемости кода.
В интернете много библиотек, упрощающих маскирование текстовых полей. Большинство популярных «взрослых» решений используют комбинации описанных нативных событий со всеми преимуществами и недостатками. Но что, если бы появилась необходимость создать новую библиотеку в 2023 году? Повторила бы она опыт своих предшественников? Есть припрятанный туз в рукаве, который мы еще не успели обсуди, — beforeinput
-событие.
Рецепт современной маски
Beforeinput — молодое событие, которое идеально подходит для маскирования инпутов. В марте 2017 года его подарил нам… Safari. Да, этот браузер умеет не только вызывать слезы фронтенд-разработчиков, но иногда и первым радовать их новыми фичами.
Следующим это свойство реализовал Chrome, а позже подхватили и другие браузеры. Отстающим крупным игроком стал Firefox, который обеспечил поддержку события лишь к 2021 году. На момент написания статьи beforeinput
имеет хорошую поддержку современными браузерами и работает в 94,59% от всех используемых версий браузеров.
У beforeinput
масса достоинств для нашей задачи:
Срабатывает только при нажатии клавиш, приводящих к изменению инпута.
Поддерживает все прочие возможности изменить инпут помимо взаимодействия с клавиатуры — у события есть поле
inputType
, которое может принимать различные значения:insertText
,insertFromDrop
,insertFromPaste
,deleteContentBackward
,deleteContentForward
и др.Событие можно отменить.
Кажется, что взяли все самое лучше от прошлых событий и объединили все в новом!
Мы получили современный рецепт для маскирования текстовых полей. Большую часть валидации значения можно производить в beforeinput
, а в input
завершать мелкие калибровки полученного события.
element.addEventListener('beforeinput', event => {
switch (event.inputType) {
case 'deleteContentBackward':
case 'deleteContentForward':
case 'deleteByCut':
return handleDelete(event);
case 'insertLineBreak':
return handleEnter(event);
case 'insertFromPaste':
case 'insertText':
return handleInsert(event);
// ...
// Many other cases
// ...
}
});
Подчеркну важную особенность. Если нужно отменить beforeinput
-событие — например, чтобы самостоятельно программно обновить инпут нужным значением, то отменится после него и последующее input
-событие. Такое поведение ожидаемо с точки зрения логики.
Иногда мы хотим сообщить о случившемся внешним наблюдателям. Хорошим примером может стать фреймворк Angular: у него есть свои инструменты для работы с формами, которые полагаются на факт, что событие input произойдет на каждое изменение значения инпута. Проблема имеет множество решений, одно из них — при отмене beforeinput
-события с последующим программным обновлением текстового поля можно самостоятельно сделать element.dispatchEvent(new InputEvent(...))
.
Коллекция библиотек Maskito
Выявленную формулу для создания масок мы применили в новой разработке Maskito. Это коллекция библиотек, написанных на Typescript. Главная библиотека @maskito/core
создана без использования внешних зависимостей, что позволяет применять ее в любом vanilla JavaScript проекте.
В Maskito есть библиотека @maskito/kit
— набор уже готовых конфигурируемых масок. А еще мы создали отдельный опциональный пакет @maskito/angular
на случай, если вы захотите использовать разработку в своем Angular-проекте. Подробнее обо всех возможностях Maskito читайте в документации. А в следующей статье расскажу подробнее, что у нас из этого получилось, и покажу немного реального кода.
GitHub — Tinkoff/maskito: Collection of libraries to create an input mask which ensures that user types value according to predefined format.
github.comMaskito уже публикуется в npm под нулевой мажорной версией и готово к использованию. Мы проводим финальные тесты, ищем и исправляем баги, чтобы совсем скоро опубликовать первую мажорную версию. Сохраняйте библиотеку в заметки — надеемся, что она пригодится вам в следующем проекте!