Рады поделиться: выложили нашу разработку Maskito в открытый доступ, и совсем недавно произошел релиз ее первой мажорной версии. Maskito — коллекция библиотек, упрощающих маскирование текстовых полей, с удобным и гибким публичным API.
Maskito содержит разные библиотеки: основная написана на TypeScript без зависимостей, есть опциональный пакет с набором готовых конфигурируемых масок, а еще есть библиотеки для удобного использования Maskito в проектах на React, Angular или Vue. Рассказываю обо всем подробнее.
Немного теории
В статье будут слова «маска», «маскирование инпутов» и прочие созвучные термины. Давайте сразу обсудим, что это значит в контексте веба.
Если мы попытаемся придумать официальное определение этого термина, оно будет звучать приблизительно так.
Маскирование — это контроль вводимых символов, чтобы финальное значение текстового поля соответствовало определенному правилу или паттерну
Важно различать термины «маскирование» и «валидация». Хотя оба процесса и преследуют похожую цель, маскирование пытается помочь пользователю при вводе значения, в то время как валидация только отвечает «да» или «нет» на вопрос о том, корректно ли получившиеся значение.
Если такое душное определение все же не прояснило суть термина, то подробнее я рассказывал про маскирование в предыдущей статье.
Или предлагаю изучить несколько примеров замаскированных инпутов: для времени, даты, числа, телефонного номера или банковской карты.
В следующих двух главах рассказываю про историю создания Maskito и причины некоторых архитектурных решений при ее разработке. Если вам это неинтересно и хочется скорее увидеть Maskito в действии, переходите к главе «Анатомия Maskito».
Немного истории
Теоретическую базу обсудили — теперь объясню, зачем понадобилось создавать новую библиотеку. Вы можете заметить, что в интернете уже есть готовые решения.
И сразу дисклеймер: не существует идеальных библиотек, фреймворков или языков программирования. Просто под каждую задачу лучше подходят определенные инструменты. Аналогично и с библиотеками для маскирования: существует несколько популярных библиотек, но, к сожалению, все они плохо подходили под наши задачи.
Моя команда разрабатывает дизайн-систему Тинькофф — мы отвечаем за поддержку Angular UI Kit, коллекции библиотек с набором готовых компонентов. Мы рассказывали про этот Open Source продукт — Taiga UI.
Среди наших компонентов есть много замаскированных текстовых полей: и для телефона, и для дат, и для времени, и для банковских карт. Я перечислил только самые яркие примеры — их в библиотеке значительно больше.
Для разработки исторически использовалась библиотека text-mask. Она давала хороший публичный API, достаточно гибкий, чтобы решить многие наши хотелки. Если бы не одно но, то вы бы сейчас и не читали эту статью, потому что мы бы продолжали пользоваться text-mask.
Поддержка библиотеки постепенно угасала, баги фиксились все менее интенсивно. В репозитории проекта до сих пор висят нерешенными баги (например, #657 и #830), открытые более пяти лет назад нашими же коллегами, которые в тот момент уже разрабатывали проект, который в будущем стал называться Taiga UI.
Плохо решаемые баги — не единственная проблема. Кодовая база с каждым днем переставала соответствовать современным стандартам. А самым печальным стало то, что в 2020 году объявили о прекращении поддержки библиотеки. Об этом сказано в корневом README-файле проекта.
Вопрос альтернативного решения для Taiga UI зрел с каждым днем, и причин было несколько:
Оставались неразрешенными вышеупомянутые долгоживущие баги.
Библиотека становилась единственной зависимостью-аутсайдером в нашем проекте: она публиковалась с использованием древних модульных систем, а применяемый Angular движок уже давно пора было сменить с ViewEngine на Ivy. На все это начинали ругаться современные билдеры, и рано или поздно это могло стать серьезной проблемой.
Мы начали изучать другие популярные решения для маскирования текстовых полей: imaskjs, cleave.js, ngx-mask и InputMask. Главное достоинство всех готовых решений, что если тебе требуется создать какую-то классическую маску, не переусложненную дополнительной логикой, то они хорошо решают поставленную задачу.
Но проблемы начинаются, когда нужно создать более сложное решение со своим особенным поведением. Библиотеки не давали нужной гибкости публичного API, как это было с нашей прошлой маской text-mask. Более того, у всех библиотек была очень скудная документация, и глубокое погружение в суть библиотеки возможно было только путем изучения исходников уже весьма винтажного кода. Конечно, это не самая критичная проблема, но раз мы уже начали перечислять все проблемы, то и об этом стоило упомянуть.
Мы пообщались с другими разработчиками, использующими эти библиотеки в своих проектах. Они упоминали, что есть ряд проблем с SSR или Shadow DOM, скаканием каретки и так далее. В общем, как я и говорил раньше, нет идеальных решений, под каждую задачу подходят разные.
Наконец, печальная остановка поддержки text-mask показала, что как бы библиотека ни была популярна среди сообщества, за ней должен стоять не один разработчик-энтузиаст, а желательно целая организация, которая всегда будет заинтересована в дальнейшем развитии.
Когда библиотека развивается для нужд компании, на нее можно положиться, ведь если текущие майнтейнеры и решат завершить работу над проектом, то корпорация просто заменит их другими сотрудниками, потом что ей важно поддерживать развитие библиотеки для собственных нужд.
Все последующие размышления, исследования и обсуждения привели к созданию собственной библиотеки для маскирования, с помощью которой мы сможем закрыть стоящие перед нами задачи.
Начало пути
При разработке новой маски мы обозначили главные направления, которых хотим придерживаться:
Контроль всех взаимодействий с текстовым полем: и классического ввода/удаления с клавиатуры, и вставки из буфера обмена, и сбрасывания текста в текстовое поле курсором, и браузерного автофила, и выбора предлагаемого текста с нативной мобильной клавиатуры.
Поддержка Server Side Rendering.
Работа не только с
HTMLInputElement
, но и cHTMLTextAreaElement
, чего не умела наша библиотека-предшественник.Маска должна состоять из нескольких библиотек, и главная из них должна быть framework-agnostic. А для проектов, написанных на популярных веб-фреймворках, планировалось опубликовать опциональные мини-библиотеки, позволяющие использовать Maskito в стиле, заданном фреймворком.
Первую задачу с широким перечнем контролируемых действий мы решали через современные возможности браузеров. Нам помогло молодое событие beforeInput
, которое в паре с уже взрослым input
-событием покрывало все необходимые случаи.
Выполнение второй задачи про SSR мы контролировали так. Все наши Cypress-тесты гоняются на SSR приложении. Если ловится хоть какая-либо ошибка на сервере, то все тесты сразу начинают валиться. Такой подход не позволяет отловить абсолютно все серверные ошибки, но положительные результаты выбранной стратегии уже получены: пару раз мы поймали ошибки до того, как они успели попасть в релиз.
После выбора основных направлений мы начали разработку. И сейчас наша библиотека Maskito готова к использованию. Она публикуется в npm, и ее можно использовать в своих проектах. А в проекте Taiga UI новая маска уже применяется для создания всех своих маскированных инпутов.
Анатомия Maskito
Maskito — это коллекция библиотек. Основной пакет среди них — @maskito/core
, легковесный пакет на 3 Кб без внешних зависимостей. Этого пакета достаточно, чтобы замаскировать инпут в каком-нибудь простеньком приложении, написанном на чистом JavaScript.
Есть еще опциональный пакет @maskito/kit
. Он включает в себя набор уже готовых масок со множеством конфигурируемых параметров. Не зависит от какого-либо веб-фреймворка. Еще есть крошечные пакеты под React, Angular и Vue. Называются они @maskito/react
, @maskito/angular
и @maskito/vue
и позволяют использовать Maskito в реакт/ангуляр/vue-стиле, то есть в виде хука или директивы.
Maskito на примере
Теперь разберемся с основными концепциями Maskito и посмотрим на упрощенный кусок кода:
import {Maskito, MaskitoOptions} from '@maskito/core';
const element: HTMLInputElement = document.querySelector('input')!;
const options: MaskitoOptions = {
mask: new RegExp('...'),
preprocessors: [
({elementState, data}) => {
return {elementState, data};
},
],
postprocessors: [
({value, selection}) => {
return {value, selection};
},
],
};
const maskedInput = new Maskito(element, options);
// Call it when the element is destroyed
maskedInput.destroy();
Главная сущность — класс Maskito
, который инициализируется с двумя аргументами. Первый — ссылка на нативный <input />
или <textarea />
, а второй аргумент — конфигурация маски.
Как только класс создался, включается прослушивание нативных событий, которые и контролируют весь получаемый от пользователя ввод значений. Единственное, о чем стоит помнить разработчику: в случае удаления маскируемого элемента из DOM нужно подчистить за ним все лишнее, вызвав у экземпляра класса единственный публичный метод destroy()
.
Расскажу подробнее про то, как правильно сконфигурировать маску, то есть про содержимое второго аргумента — объекта, который в блоке кода выше имплементировал интерфейс MaskitoOptions
. Поставим задачу написать простенькую маску для ввода чисел и будем итеративно ее улучшать, демонстрируя возможности Maskito.
Обязательное поле — это mask
. Выражение задает тот самый паттерн, которому должно соответствовать финальное значение в текстовом поле после всех валидаций. Оно может быть задано классическим регулярным выражением, а может — через массив мини-регулярных выражений. Последний вариант более сложный, нужен для масок с фиксированным количеством символов.
В рамках статьи опустим более сложный вариант. Он хорошо описан в документации, оставим его в качестве дополнительного чтения. Для нашей задачи подходит вариант с классическим регулярным выражением. Полученный набор конфигураций для маски ввода чисел будет выглядеть следующим образом:
const maskitoOptions: MaskitoOptions = {
mask: /^\d+(,\d*)?$/,
};
В поле mask
мы передали регулярное выражение, которое задает паттерн для ввода числа с опциональной дробной частью, где в качестве разделителя используется запятая.
Усложним задачу. Некоторые в качестве разделителя целой и дробной части привыкли использовать запятую, а другие могут возразить и заявить, что точка — более широко используемый разделитель.
Если попытаемся ввести точку в текущем варианте маски, то маска откажет во вводе и получится плохой UX. Можно разрешить ввод точки в регулярке, но тогда при нажатии точки в значении инпута и будет точка.
Представим, что в нашем примере дизайн система диктует использовать именно запятую. Стоит задача, чтобы при нажатии пользователем точки в текстовое поле подставлялась запятая.
Тут на помощь приходит опциональное поле из интерфейса MaskitoOptions
— preprocessors
. Препроцессоры позволяют разработчику добавить свои кастомные мутации значения перед тем, как маска начнет свою работу. Отредактированное значение из препроцессоров уже попадает на обработку маске.
Сам препроцессор — функция, получающая на вход текущее состояние элемента: его значение вместе с начальным и конечным положениями выделения текста. А еще значение data из нативного события, которое было создано после взаимодействия пользователя с текстовым полем. При вводе значений в data
содержится новый введенный символ. И препроцессор в качестве возвращаемого значения ожидает объект с таким же интерфейсом. Разработчик может подменить эти значения, а может и оставить такими же. Реализуем поставленную задачу с подменой точки на запятую:
import {MaskitoOptions} from '@maskito/core';
const maskitoOptions: MaskitoOptions = {
mask: /^\d+(,\d*)?$/,
// 0.42 => 0,42
preprocessors: [
({elementState, data}) => {
const {value, selection} = elementState;
return {
elementState: {
selection,
value: value.replace('.', ','),
},
data: data.replace('.', ','),
};
},
],
};
Важно, что точка на запятую подменяется не только у поля
data
, но и у значенияvalue
! Объясняется это тем, что хотя для большинства случаев мутацииdata
достаточно, но существует единственный редкий случай, когда в value может попасть невалидная нам точка, — это браузерный автофил. Современные браузеры не трегирятbeforeinput
-события при таком действии, ограничиваясь однимinput
-событием.
Сделаем последнее улучшение нашей маски для ввода чисел и добавим следующее поведение: если пользователь попытался вставить число с большим количеством ведущих нулей в самом начале, то сбрось лишние. То есть при попытке вставить число 000,42
в текстовом поле должно остаться 0,42
.
Для этой задачи идеально подойдет новое опциональное поле из интерфейса MaskitoOptions
— postprocessors
. Аналогично своему коллеге препроцессору, постпроцессор — это функция, созданная для корректировки значения текстового поля для реализации своей особой логики. Постпроцессоры вызываются после завершения работы маски, когда та отбросила все невалидные символы и привела значение инпута к нужному значению.
Постпроцессор первым параметром принимает получившееся состояние элемента, то есть новое значение инпута и новые позиции выделения текста. В качестве возвращаемого значения он ожидает объект с таким же интерфейсом, что и получил на входе, но разрешает изменить значение любого из полей. И новая версия конфигурации маски будет выглядеть следующим образом:
import {MaskitoOptions} from '@maskito/core';
const maskitoOptions: MaskitoOptions = {
mask: /^\d+(,\d*)?$/,
preprocessors: [
({elementState, data}) => {
const {value, selection} = elementState;
return {
elementState: {
selection,
value: value.replace('.', ','),
},
data: data.replace('.', ','),
};
},
],
// 000000.42 => 0.42
postprocessors: [
({value, selection}) => {
const [from, to] = selection;
const newValue = value.replace(/^0+/, '0');
const deletedChars = value.length - newValue.length;
return {
value: newValue,
selection: [from - deletedChars, to - deletedChars],
};
},
],
};
При работе с постпроцессорами не стоит забывать, что это самая финальная ступень в валидации значения инпута. Можно делать любую обработку, но в конце нужно убедиться, что финально будет возвращаться состояние с валидным значением.
Постпроцессор дает огромную гибкость, но, как говорил дядя Бен, «с большой силой приходит большая ответственность».
Вот так мы создали простую маску для ввода чисел и познакомились с основными концепциями Maskito! Финальную версию созданного нами примера можно изучить в StackBlitz-примере.
Вместо заключения
Я успел рассказать только про самые важные концепции Maskito. Но этим не ограничиваются все возможности данного инструмента. Maskito умеет еще больше, о чем можно детально почитать в документации.
Если вам понравился наш новый продукт, прошу поддержать его звездочкой на Гитхабе. Мы всегда рады вашей обратной связи! А если столкнетесь с какими-либо проблемами, то заведите нам задачу — мы обязательно все поправим!
Комментарии (13)
Str5Uts
21.06.2023 13:36+3Как насчёт поддержки разных локалей? А то пример с 0.42 => 0,42 выглядит как потенциальная проблема. Или даты, тоже сильно геморойно для разных стран.
nsbarsukov Автор
21.06.2023 13:36+1Серебряной пули в данном вопросе, к сожалению, нет.
То есть, если ваша маска требует поддержки разных локалей, то придется пописать немного кода.Но Maskito уже сейчас предоставляет несколько встроенных конфигурируемых популярных масок (пакет
@maskito/kit
), которые пытаются учитывать особенности разных стран.
Например: маска Number позволяет легко задавать своиdecimalSeparator
иthousandSeparator
, маска Date предоставляет разные форматы дат (dd/mm/yyyy
,yyyy/mm/dd
,mm/dd/yyyy
), а маска Time дает возможность настраивать 12/24-часовой формат времени.Пакет
@maskito/kit
будет продолжать расширяться популярными решениями. И, разумеется, мы будем продолжать всегда держать в уме вопросы локализации.markmariner
21.06.2023 13:36-2Зачем вообще мешать пользователю писать дату в любом удобном ему формате?
Почему ваши формы заставляют пользователей указывать дату, к примеру, в формате 01.05.1999? Почему пользователь не может написать "1 января 1999 года"? Кажется, что компьютер может справится с задачей парсинга и переспросить, если не справился.
Почему я должен задавать номер телефона в виде +7 (999) 997-77-88, если мне хочется задавать его в виде +7 999 99-777-88? Почему я не могу так сделать, тем более, если номер станет лучше читаться? Зачем вообще скобочки вокруг кода города, если его обязательно набирать?
Как мне объяснить бабушке по телефону, что у неё не сломалась клавиатура, а просто программисты запрещают писать в некоторых полях ввода буквы, но не предупреждают об этом?
Кажется, что вся проблема "маскирования" заключается в том, что вместо того, чтобы тратить силы на поддержку любого удобного пользователю ввода, вы тратите силы на то, чтобы запретить пользователю вводить данные в любом формате, отличном от того, который используется в вашей БД, ну или просто от "единого верного способа".
Str5Uts
21.06.2023 13:36Ну если у нас например многонациональная аудитория и в какой-нибудь
Финляндии 10/11/1999 будет десятое ноября, а в штатах это будет одинадцатое октября.запрещают писать в некоторых полях ввода буквы, но не предупреждают об этом?
Посмотрите как это делается например в material design, почему «но не предупреждают»?
Например поддержка локализации там сделана вплоне хорошо.markmariner
21.06.2023 13:36И какая в этом проблема? Просто вычислите, кто к вам пришёл и откуда, и сделайте вывод. Не уверены? Переспросите.
В текущем интерфейсе у Тинкоффа этот вопрос тоже не решён: там предлагают заполнить 01.04.1999, ну окей, я зашёл из Америки и заполнил привычно. И ошибся. Если день не больше 12, то никто и не заметит подмены, ни я, ни сотрудник банка.
На самом деле, у меня даже была именно такая проблема как-то в ВТБ. Сотрудник перепутал день и месяц моего рождения в поле ввода и мне стоило потом больших усилий доказать, что я -- это я. И никакая маскификация не помогла.
По второму вопросу по ссылке, как раз сделано верно, ничего не запрещают вводить, клавиатура работает. Но в Taiga UI как раз буквы запрещены с помощью той самой библиотеки из статьи.
Str5Uts
21.06.2023 13:36Проблемы нету, если библиотека поддерживает локализацию. В этом и был вопрос.
Переспросите
Есть UX, переспрашивание это его ухудшение и огромные затраты поддержание избыточной логики.
Вот например как сделан выбор даты в том же material design
https://material.angular.io/components/datepicker/examplesИ никакая маскификация не помогла.
Это уже поперечная проблема — к маскированию отношение очень опосредованно. В основном правильный оргпроцесс для решения граничных случаев.markmariner
21.06.2023 13:36Плохой UX -- не когда ты переспрашиваешь у пользователя, если неуверен, а когда ты заполняешь базу неверными данными или вообще не даёшь пользователю заполнить форму и просто теряешь клиента. Лучший способ этого достичь -- ввести маски в полях и валидацию ввода.
Мой имейл, к примеру, содержит 2 символа до собачки, множество раз я не мог даже зарегистрироваться где-то просто потому, что программист решил, что такого не бывает.
Str5Uts
21.06.2023 13:36Мой имейл, к примеру, содержит 2 символа до собачки
Для подобных случаев сделайте алиас с пятью или используйте формат типа ab+cde@gmail.com, всё что после плюса игнорируется.
Смысл бороться с ветрянными мельницами.
Есть люди с фамилией из одной буквы. им гораздо интереснее живётся.просто теряешь клиента
Если ты за счёт удобства 95% клиентов получаешь +10% от прибыли, то потеря тех «уникальных» 5% процентов которые к тебе не придут, это выгодная сделка.markmariner
21.06.2023 13:36Кто хотел писать по маске, тот по маске может писать и без нее, никакого удобства тут нет ни для кого, кроме разработчика.
Я-то напишу дополнительные символы в имейле, а неайтишник — нет
И борюсь я не с ветряными мельницами, а с плохим интерфейсом.
TachikomaGT
21.06.2023 13:36+1Дизайнеры трудятся, маски отрисовывают, программисты трудятся, думают как заимплементить, во всех браузерах желательно, тестировщики баги ищут. Называется, ответственная социальная политика =)
Если серьёзно, то согласен с вами, все эти причинения удобств могут быть, как правило заменены несколькими replace-ами на стороне приведения данных к единому формату.
Lex20
Вот тут https://github.com/Tinkoff/maskito/blob/main/projects/core/src/lib/mask.ts#L214 очень неаккуратный обработчик. Клавишу insert не обрабатывает. Чтобы такого не городить используйте скрытый input или textarea.
nsbarsukov Автор
Вы правильно подметили, что данный обработчик
handleKeydown
обрабатывает далеко не все возможные кейсы.Но, к счастью, он существует лишь для древних версий Firefox, которые не поддерживают нативное beforeinput событие (которое уже гораздо лучше справляется с огромным перечнем возможных способов взаимодействия пользователя с текстовым полем). Для своих бизнесовых потребностей нам пока еще нужна частичная поддержка старого Firefox.
Для всех остальных современных браузеров мы уже не используем событие `keydown`, а опираемся на комбинацию `beforeinput`/`input` событий.
Подробнее об всем этом можно почитать в моей предыдущей статье "Трудности маскирования текстового поля".