Maskito – новая коллекция библиотек для маскирования текстовых полей

Рады поделиться: выложили нашу разработку Maskito в открытый доступ, и совсем недавно произошел релиз ее первой мажорной версии. Maskito — коллекция библиотек, упрощающих маскирование текстовых полей, с удобным и гибким публичным API.

Maskito содержит разные библиотеки: основная написана на TypeScript без зависимостей, есть опциональный пакет с набором готовых конфигурируемых масок, а еще есть библиотеки для удобного использования Maskito в проектах на React, Angular или Vue. Рассказываю обо всем подробнее.

3f488131fb969a7ced445bb66b337f09.png

Немного теории

В статье будут слова «маска», «маскирование инпутов» и прочие созвучные термины. Давайте сразу обсудим, что это значит в контексте веба. 

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

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

Важно различать термины «маскирование» и «валидация». Хотя оба процесса и преследуют похожую цель, маскирование пытается помочь пользователю при вводе значения, в то время как валидация только отвечает «да» или «нет» на вопрос о том, корректно ли получившиеся значение.

Если такое душное определение все же не прояснило суть термина, то подробнее я рассказывал про маскирование в предыдущей статье.

Трудности маскирования текстового поля

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

habr.com

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

В следующих двух главах рассказываю про историю создания Maskito и причины некоторых архитектурных решений при ее разработке. Если вам это неинтересно и хочется скорее увидеть Maskito в действии, переходите к главе «Анатомия Maskito».

Немного истории

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

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

Моя команда разрабатывает дизайн-систему Тинькофф — мы отвечаем за поддержку Angular UI Kit, коллекции библиотек с набором готовых компонентов. Мы рассказывали про этот Open Source продукт — Taiga UI.

Среди наших компонентов есть много замаскированных текстовых полей: и для телефона, и для дат, и для времени, и для банковских карт. Я перечислил только самые яркие примеры — их в библиотеке значительно больше. 

Для разработки исторически использовалась библиотека text-mask. Она давала хороший публичный API, достаточно гибкий, чтобы решить многие наши хотелки. Если бы не одно но, то вы бы сейчас и не читали эту статью, потому что мы бы продолжали пользоваться text-mask. 

Поддержка библиотеки постепенно угасала, баги фиксились все менее интенсивно. В репозитории проекта до сих пор висят нерешенными баги (например, #657 и #830), открытые более пяти лет назад нашими же коллегами, которые в тот момент уже разрабатывали проект, который в будущем стал называться Taiga UI.

Плохо решаемые баги — не единственная проблема. Кодовая база с каждым днем переставала соответствовать современным стандартам. А самым печальным стало то, что в 2020 году объявили о прекращении поддержки библиотеки. Об этом сказано в корневом README-файле проекта.

Вопрос альтернативного решения для Taiga UI зрел с каждым днем, и причин было несколько:

  1. Оставались неразрешенными вышеупомянутые долгоживущие баги. 

  2. Библиотека становилась единственной зависимостью-аутсайдером в нашем проекте: она публиковалась с использованием древних модульных систем, а применяемый Angular движок уже давно пора было сменить с ViewEngine на Ivy. На все это начинали ругаться современные билдеры, и рано или поздно это могло стать серьезной проблемой.

Мы начали изучать другие популярные решения для маскирования текстовых полей: imaskjs, cleave.js, ngx-mask и InputMask. Главное достоинство всех готовых решений, что если тебе требуется создать какую-то классическую маску, не переусложненную дополнительной логикой, то они хорошо решают поставленную задачу.

Но проблемы начинаются, когда нужно создать более сложное решение со своим особенным поведением. Библиотеки не давали нужной гибкости публичного API, как это было с нашей прошлой маской text-mask. Более того, у всех библиотек была очень скудная документация, и глубокое погружение в суть библиотеки возможно было только путем изучения исходников уже весьма винтажного кода. Конечно, это не самая критичная проблема, но раз мы уже начали перечислять все проблемы, то и об этом стоило упомянуть.

Мы пообщались с другими разработчиками, использующими эти библиотеки в своих проектах. Они упоминали, что есть ряд проблем с SSR или Shadow DOM, скаканием каретки и так далее. В общем, как я и говорил раньше, нет идеальных решений, под каждую задачу подходят разные.

Наконец, печальная остановка поддержки text-mask показала, что как бы библиотека ни была популярна среди сообщества, за ней должен стоять не один разработчик-энтузиаст, а желательно целая организация, которая всегда будет заинтересована в дальнейшем развитии. 

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

Все последующие размышления, исследования и обсуждения привели к созданию собственной библиотеки для маскирования, с помощью которой мы сможем закрыть стоящие перед нами задачи.

Начало пути

При разработке новой маски мы обозначили главные направления, которых хотим придерживаться:

  1. Контроль всех взаимодействий с текстовым полем: и классического ввода/удаления с клавиатуры, и вставки из буфера обмена, и сбрасывания текста в текстовое поле курсором, и браузерного автофила, и выбора предлагаемого текста с нативной мобильной клавиатуры.

  2. Поддержка Server Side Rendering.

  3. Работа не только с HTMLInputElement, но и c HTMLTextAreaElement, чего не умела наша библиотека-предшественник.

  4. Маска должна состоять из нескольких библиотек, и главная из них должна быть 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, который инициализируется с двумя аргументами. Первый — ссылка на нативный или , а второй аргумент — конфигурация маски.

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

Если вам понравился наш новый продукт, прошу поддержать его звездочкой на Гитхабе. Мы всегда рады вашей обратной связи! А если столкнетесь с какими-либо проблемами, то заведите нам задачу — мы обязательно все поправим!

© Habrahabr.ru