Один античный оратор говорил, что всем людям свойственно ошибаться. Прошло много веков, а человек продолжает совершать ошибки каждый день. Даже беглое заполнение формы на сайте не обходится без опечаток. 

Хороший UI/UX помогает пользователю избежать большинства таких проблем. Инструментов контроля огромное количество, сегодня расскажу про один их них — создание маски для поля ввода силами Javascript.

Да что такое, эта ваша маска

Представим ситуацию, что сайт хочет запросить данные, которые должны содержать только цифры. Например, для поля ввода цены товара мы не хотим разрешать пользователю вводить буквы и прочие символы. 

Читатель может вспомнить, что в HTML уже есть <input type="number" />. Открываем наш любимый Chrome, страницу с документацией элемента, пробуем ввести что-то кроме цифр и точки или запятой… ура! Браузер запрещает это сделать, и при попытке ввода невалидного символа значение инпута не изменяется. Кажется, что проблема решилась быстро, пора и статью завершать на этой удачной находке! 

Но погодите. Давайте откроем Firefox и повторим те же действия. К несчастью, этот браузер менее строг к вводу невалидных символов и лишь при отправке формы начинает выдавать предупреждение:

То, что сделал Firefox в нашем примере, — это издевательство один из способов предупредить пользователя о невалидном значении. Но, кажется, не очень своевременный. А вот Chrome показал один из примеров маскирования инпута.

Маска — это контролирование вводимых пользователем символов, чтобы значение текстового поля соответствовало определенному правилу или паттерну.

Пример с вводом чисел — один вид маски из множества возможных. Существуют более сложные примеры: ввод времени, даты или телефонного номера.

Маска может не только предотвратить ввод невалидных значений, но и помочь пользователю добавить нужные символы. Например, поставить пробелы между тысячными разрядами числа или разделители между днем/месяцем/годом в дате. 

Маска может даже угадывать намерения пользователя: подставлять в поле ввода точку в качестве разделителя целой и дробной части, если в поле ввода пользователь нажимает на клавиатуре букву «ю».

Маскирование инпута — это скорее про повышение UX, чем про валидацию данных. Опытный злоумышленник способен обмануть любое фронтовое веб-приложение. Поэтому дополнительная финальная валидация данных на беке всегда нужна!

Ингредиенты маски

Нужно разобраться в большом списке событий, которые возникают у элементов <input /> и <textarea />, чтобы контролировать вводимые значения в текстовом поле. Расскажу об основных.

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 читайте в документации. А в следующей статье расскажу подробнее, что у нас из этого получилось, и покажу немного реального кода.

Maskito уже публикуется в npm под нулевой мажорной версией и готово к использованию. Мы проводим финальные тесты, ищем и исправляем баги, чтобы совсем скоро опубликовать первую мажорную версию. Сохраняйте библиотеку в заметки — надеемся, что она пригодится вам в следующем проекте!

Комментарии (16)


  1. osj
    07.04.2023 10:54
    +1

    Вот бы Rutube прочитал статью и начал правильно email адреса с + обрабатывать и ошибки отображать, что в мобильном приложении, что на сайте.


  1. TachikomaGT
    07.04.2023 10:54
    +12

    На первый взгляд выглядит не так плохо, но я по-прежнему убеждён что если можно не маскировать (а это можно практически всегда), лучше этого не делать. Особенно с номерами телефонов, хотя к остальным полям тоже относится. Пара примеров из тестов данного проекта просто чтобы отговорить тех, кто считает что сейчас быстренько плагин маскирования, этот или другой, накинет и всё будет хорошо.

    1) Вы можете не знать всех нюансов данных, которые вы запрашиваете.

    Например, email. Или почтовый адрес. Или номер карты. В данном случае с картой всё выглядит хорошо: payment card number до 19 символов, autocomplete расставляет всё по своим местам. Но вот на попытку маскирования email я бы посмотрел.

    2) Современные браузеры приучили нас не запоминать номера карточек или телефонов.

    Эти данные хранятся в телефоне, как номера контактов, или в браузерах, как данные карт. И люди, не часто пользующиеся роумингом могут спокойно всю жизнь использовать номера с префиксом 8 вместо +7. И тут маскировщики при вставке номера с другим, но корректным префиксом начинают резко ломаться, отъедая последние цифры номера, к сожалению, данный не исключение.

    3) Прочие мелкие досадные проблемы.

    У кого-то, как у госуслуг, вставка некорректно начинает работать. Здесь попроще: в expiry date нельзя ввести нолик в месяц начать писать, хотя placeholder mm/yy.

    В любом случае, спасибо за перечисление ингредиентов, освежить их в памяти всегда полезно!


    1. Waterplea
      07.04.2023 10:54

      Спасибо за подсвеченные моменты. К версии 1.0.0 обязательно поправим!


    1. dom1n1k
      07.04.2023 10:54
      +2

      Как-то имел большой спор с товарищем (хороший очень опытный программист) на тему маскирования поля с телефоном.
      Я говорил, что пусть вводят как хотят, в любом формате — в чем проблема убрать из строки все пробелы/дефисы/скобки/етц, оставить только цифры и так записать в базу? А он отстаивал вариант с маской — мол, помимо удобства программиста, так и пользователю тоже удобнее, потому что подсказывает формат данных и снимает вопрос "а что от меня хотят?"
      Спорили долго, но каждый тогда остался при своем мнении.


      1. TachikomaGT
        07.04.2023 10:54
        +1

        Я тоже придерживаюсь вашей позиции. Подсказать формат можно в текстовом описании рядом с полем, обычный placeholder всё равно во время ввода уже будет не виден. Плюс, провести вариацию поля и показать галочкой рядом с полем что всё хорошо. В идеале, правилом, подтянутым с бэкенда, а то, бывает, разъезжаются между бэком и фронтом.


      1. anzay911
        07.04.2023 10:54

        В базе хранить цифры, пользователю показывать производные комфортные значения.


        1. Ionenice
          07.04.2023 10:54

          Какие цифры хранить, которые ввёл пользователь или отформатированные под определённый формат? Если с номером не нужно никак работать и достаточно только показать его, то конечно можно хранить как угодно. Только вот, если номер нужно использовать и номера могут быть разных стран, то начинаются проблемы. Нужно как-то валидировать, форматировать, быть удобным для пользователя, быть валидным для использования. Всё это должно поддерживать последние изменения, когда, например, добавляют новый код оператора… И тут действительно всё сложно


          1. Waterplea
            07.04.2023 10:54

            У нас в Тинькофф принято работать с форматом +XXXXXXXXXX, а выводить отформатированным. Разумеется 8 автозаменяется на +7 и при написании, и при вставке/дропе, и при автофилле. Как и все посторонние символы вроде пробелов и дефисов заменяются на заданный формат.

            Красиво отформатированный ввод, если он сделан корректно, создаёт у пользователя ощущение опрятности интерфейса, помогает ему сверить значение (например разделение тысяч пробелом при форматировании суммы), не препятствует вводу и исправляет ошибки (например, если забыл переключить язык и набираешь точку/запятую/ю/б при вводе копеек). То, что это сложно сделать хорошо не повод отказываться от преимуществ, которые это даёт продукту. Наше дело, как фронтендеров, дать пользователю интерфейс, решающий его задачу настолько хорошо, насколько мы можем. Будем стараться!

            На странице просто базовый международный пример, который не хотелось усложнять, так как 8/+7 чисто русская тема. Когда выпустим maskito 1.0 и начнём на него переходить, то обязательно всё это учтём :)


            1. werwolflg
              07.04.2023 10:54

              >8/+7 чисто русская тема

              У других 0 вместо 8, а проблема та же. Те же британцы тоже не пишут +44, даже когда дают свои номера иностранцам пишут номер с внутренним кодом.


              1. Waterplea
                07.04.2023 10:54

                Верно. Тут уже зависит от продукта, главное, чтобы маска позволяла это технически решить. Посмотрим, что у нас из этого выйдет.


                1. werwolflg
                  07.04.2023 10:54

                  И больше всего проблем начинает возникать когда проект международный, либо позволяет использовать телефоны из других стран, тогда от маски начинается только боль и страдания.


            1. Ionenice
              07.04.2023 10:54

              То, что это сложно сделать хорошо не повод отказываться от преимуществ, которые это даёт продукту

              Это не так сложно сделать в контексте одной страны, но как вам уже и ответили - это сложно для международных проектов.

              У нас в Тинькофф…

              … ввожу код 999 в форму авторизации и вижу 500 ошибку с текстом «у нас проблема»

              … ввожу номер с неверным кодом, пытаюсь исправить код: одна цифра заменяется, дальше курсор прыгает в конец

              … ввожу 89011231212 получаю сообщение, что номер невалиден


              1. Waterplea
                07.04.2023 10:54

                Вот как раз для того, чтобы такого не было, а было одно централизованное хорошее решение с выделенными ответственными людьми и была создана библиотека, которая упоминается в конце статьи :) Кроме того, она будет в open source и, надеюсь, пригодится не только нам. Но сначала надо её доделать и внедрить.


    1. werwolflg
      07.04.2023 10:54
      +1

      С кодом страны и внутренним кодом больше всего проблем и возникает при таких масках. Лучше всего оставлять проверку на минимальную длину номера. Да и с почтой весело, когда многие маски ломаются, если в адресе почты есть +.


  1. dom1n1k
    07.04.2023 10:54

    У меня возник запрос на подробности, как лучше держать в одном репозитории 3 npm-пакета.
    Как удобнее организовать их взаимосвязи, тесты, демки, должны ли они тянуть друга как зависимости или пусть пользователь вручную разбирается — и так далее.


    1. nsbarsukov Автор
      07.04.2023 10:54

      Многие заботы взял на себя Nx.

      А про зависимости. Главный пакет не имеет зависимостей, а все прочие опциональные пакеты заявляют их через peerDependencies.

      Это не последняя статья про Maskito, так что учту этот запрос в будущей статье.