Речь пойдет об очередном решении многолетней проблемы в браузере — ограничение пользовательского ввода, или просто — маска, которая используется повсеместно: номера телефонов, кредитных карт, паспорта и т.д.
На данный момент было найдено два популярных решения:
Те, кто пытался использовать маски в своих и без того непростых проектах, скорее всего были бы рады выбросить все это дело и использовать просто валидацию. Особенно если маска должна быть динамической, зависеть от уже введенных символов, нужна возможность получать размаскированное значение даже если пользователь ввел его не целиком, или нужно полностью скрыть placeholder… Что работало в одной библиотеке — не работало в другой, как только извращаться не приходилось. Уж проще самому написать, в конце то концов, программисты мы или кто!? Да и коллеги тоже не потерялись, написали под Android же.
Кому не терпится, вот оно: imaskjs.
Поломать демку можно здесь.
А я продолжу по порядку. Что получилось:
- нет внешних зависимостей
- в любой момент можно получить или установить сырое или размаскированное значение
- опциональные символы (жадный алгоритм)
- размаскированные значения могут содержать фиксированные части маски
- 2 режима отображения placeholder: постоянный и ленивый (отсутствует, но появляется только тогда, когда обязательные символы ввода находятся в середине текста и сместить символы справа на их место нельзя)
- IE11+ из коробки или см. ниже
Кратко как использовать:
var element = document.getElementById('selector');
var mask = new IMask(element, {
mask: '+{7}(000)000-00-00'
});
mask.rawValue = '999-12-12-123';
console.log(mask.unmaskedValue); // 79991212123
Где, mask может быть регуляркой (нет, я не сделал свой движок), шаблоном, функцией, либо наследником определенного класса.
В данном случае используется шаблон, как самый востребованный тип, дальнейшее повествование пойдет о нем.
Итак, немногочисленные правила шаблона:
0 — вводимая цифра
a — вводимая буква
* — вводимый любой символ
[] — делает ввод опциональным
{} — делает фиксированные символы частью размаскированного значения
А еще можно подписываться на события, использовать собственные функции для проверки и др. Подробнее в документации.
На этом можно было бы в принципе и закончить, не вижу смысла приводить сложные примеры и делать здесь туториал, но хотелось бы описать некоторые принципы и заключения, которые легли в основу функционала.
Особенности работы
Основной задачей было создать минимальный работающий инструмент.
То, что в поле находится маска, не отменяет стандартного, привычного пользователю способа ввода. По моему мнению, использовать цикличный сдвиг или режим insert в случае маски некорректно.
При конфликте вставки пропускаются символы вставляемого значения. Если символ совпадает с фиксированным — он попадает на его место при вставке сырого значения, либо является частью ввода. Самый очевидный и простой вариант. Возможны более сложные не жадные алгоритмы, но оно не стоит того.
Указание опциональности относится только к вводимым символам, фиксированные символы внутри [] по прежнему остаются частью placeholder'а, если он должен отображаться. Иначе не всегда можно определить когда показывать фиксированные символы (например: «0[00-0]0» — не очевидно в каких случаях отображать "-").
Также существует проблема в случае, когда обязательные или фиксированные символы идут после опциональных, при этом их определения совпадают, т.е. символ может находится в любой позиции. Если попытаться наложить такую маску на значение возникает множество возможных очевидных и не очень случаев. Например если в начало поля ввода с маской “[000]01110222”, которая отображается в виде “_111_222” вставить значение “21113222”, то логично было бы увидеть его же, но поскольку вначале идут не опциональные символы, то значение окажется “21111113222”. Решение в общем виде нетривиально. Судя по всему, для расстановки требуется дополнительная информация о “дырках”. Но я вижу проблему больше в самой постановке задачи — для этого маски не предназначены, поэтому сделано проще — при вставке значения все символы обладают одинаковым приоритетом.
Выключен on drop. Решение есть, но выбивается из общего подхода (см. ниже).
Одной из опций в библиотеках-аналогах — это возможность указывать количество повторяемых символов. Например jquery.inputmask позволяет указывать количество символов:
Inputmask("9-a{1,3}9{1,3}").mask(selector); // от 1 до 3 символов
Inputmask("9", { repeat: 10 }).mask(selector); // от 0 до 10
Inputmask("9{*}").mask(selector); // и даже сколько угодно!
Вряд ли повтор символов является функционалом маски, да и ES6 давно на дворе. А насчет бесконечных масок — отдельный разговор.
Существует великий соблазн сделать из маски навороченный комбайн для всех возможных вариантов: «Ведь пользователь может и с „+7“ начать и „8“ захотеть ввести...» Поубивал бы!
В моем случае возможность альтернативных частей маски не реализована, но существующие решения явно не устраивают.
Например, в jquery.inputmask можно сделать так:
Inputmask("(aaa)|(999)").mask(selector); // либо 3 буквы, либо 3 цифры
Только для случаев посложнее приходится сочинять свои группы символов (например для дат), и обозначенное автором преимущество алиасов в «сокрытии сложности» только ее добавляет. Да и сам формат становится неудобным.
Короче, нужно быть проще, используйте маски для простых случаев. В моей реализации есть вариант использовать не шаблон, а функцию, и дальше все руками.
Разбор внутренностей
Всегда хочется сделать код одновременно проще и универсальнее. В моем случае я добивался этого, пожертвовав поддержкой старых браузеров, хотя возможно не все так плохо как кажется, кто знает.
Итак, в отличие от коллег, я не стал создавать сложный объект для каждого символа маски (Slot в аналоге), а пожертвовал этим уровнем абстракции и сделал вот как:
- Строковое описание маски преобразуется в неизменяемый список объектов с полями:
- символ (используется для фиксированных символов)
- тип (фиксированный, либо ввод)
- опциональный или нет
- должен ли включаться в размаскированное значение
- Описания символов (definitions) преобразуются в неизменяемый объект вида {<definition>: <BaseMask object>}, где BaseMask, как вы уже наверно догадались, рекурсивно определенная маска или проще — валидатор для конкретного типа символа.
- Хранится изменяемый список «дырок» — незаполненных символов ввода.
- Хранится последнее корректное состояние — значение поля и положение курсора.
Теперь о динамике
Перед любым изменением нужно сохранить состояние. По умолчанию — на событие keydown.
Далее даем пользователю ввести все, что он хочет, а после — корректируем. По умолчанию — на событие input.
Имея на руках состояние до изменения и состояние после, можно вычислить корректное значение и положение курсора.
Кратко про самую интересную часть алгоритма
При применении маски в первую очередь определяется интервал изменений (изменения — это вставка, удаление или замена). Символы, стоящие до интервала изменений, считаются корректными, и подставляются как есть. Из подстроки после интервала изменений, извлекаются все символы ввода.
Затем, если была вставка, то подставляется максимально возможное количество вставленных символов с учетом извлеченных символов ввода после интервала. Если было удаление — символы удаляются и подставляется минимально необходимый placeholder.
Суть в том, что я не проверяю, корректен ли каждый введенный символ, а вставляю весь блок заново. Так проще и надежнее сделать обтекание фиксированных символов.
Также такое решение позволяет единообразно обрабатывать различные возможные способы изменения значения: непосредственный ввод, вставка, вырезание, отмену изменений, а также изменения программно. Универсально для любых изменений любых последовательностей символов.
Как одно из особенностей получившегося решения (хотя скорее всего это особенность всех решений с масками) — необходимость уведомлять маску, если значение просочилось мимо библиотеки. Например, другой плагин подсовывает значение непосредственно в свойство value элемента. В таких случаях необходимо вручную вызывать метод refresh для наложения маски.
Вместо заключения
Выключил drop, взял keydown и input, и никаких хитрых хаков с кодами клавиш и прочей нечистью. Даже на paste не завязывался.
Если же вам нужна поддержка старых браузеров или в мобиле поехало — повесьте обработчик на события, которые нужны вам. И если это не сильно захламит код — пул реквесты приветствуются.
Библиотека еще довольна сырая, но надеюсь начнет скоро экономить нервы.
Буду рад замечаниям и предложениям.
Комментарии (14)
thinking
06.03.2017 01:39+1Рекомендую: Cleave.js
Без зависимостей, npm/bower…burfee
06.03.2017 10:29+1Спасибо. Пожалуй неплохой вариант для для часто используемых случаев: телефонов, дат, цифр.
Но в наших проектах нужна именно кастомная маска для всяких номеров договоров с мешаниной из фиксированных/вводимых символов, и насколько я понял такого функционала нет — максимум ограничение по блокам. В моем случае целью было сделать максимально простое низкоуровневое решение, на основе которого можно собирать конкретные варианты для переиспользования, вроде дат и т.п. Присмотрюсь к возможному API.
kemsky
06.03.2017 03:31+2У всех маск плагинов есть общие беды, основная это конфигурация и поддерживаемые типы масок, вторая это баги (+ ломаются на патч версиях), третья — баги. Вашу я тоже сломал, вветите номер телефона 778 и поставьте курсор в начало — delete не работает.
Меня эта ситуация с масками привела к необходимости заменить их на валидаторы, только кое-где оставить например number-only.
CrazyNiger
06.03.2017 09:05+1Еще «баги»:
- Содержимое инпута с телефоном не выделяется мышкой (выделяется, но выделение тут же сбрасывается)
- Если выделить часть введенного с помощью шифта и стрелок (например часть в скобках), с целью переписать, по после ввода первого символа курсор проскочит за следующий символ
burfee
06.03.2017 10:12Спасибо! поправлю. Предлагаю писать в личку или в issues на github, чтобы не делать из хабра багтрекер.
DiMoNTD
06.03.2017 10:12+3Честно сказать перепробовал много всяких библиотек для маскирования. У всех будут так или иначе баги. Особенно если их использовать под мобильные приложения, и пилить еще одну собственную реализацию не вижу ну никакого смысла. Лучше использовать что-то готовое и поддерживаемое сообществом. В статье написано про две библиотеки с зависимостями jQuery, но на самом деле их гораздо больше и вообще на любой вкус.
Сейчас лично я использую вот такую библиотеку:
https://github.com/text-mask/text-mask
Как по мне, очень круто сделали ребята, что портировали ее под все популярные фреймворки:
https://github.com/text-mask/text-mask/tree/master/angular1
https://github.com/text-mask/text-mask/tree/master/angular2
https://github.com/text-mask/text-mask/tree/master/ember
https://github.com/text-mask/text-mask/tree/master/react
https://github.com/text-mask/text-mask/tree/master/vue
оставили ванильную версию:
https://github.com/text-mask/text-mask/tree/master/vanilla
да еще и выделили ядро, которое можно использовать для реализации своей кастомной библиотеки:
https://github.com/text-mask/text-mask/tree/master/core
А главное — проект поддерживается по сегодняшний день.burfee
06.03.2017 10:35Спасибо, вот это кто третий с картинки :)
Может и нет смысла, время покажет, но руки чесались и пошло оно как-то. Да и помогает.
Но по сторонам надо видимо больше смотреть, согласен.
impetus
06.03.2017 10:25Попробовал в «демке» ввести свой рабочий номер — московский городской с добавочным внутренним (офисная АТС-ка сразу перекидывывает на меня). Не получилось. (Т.е. звонок уйдёт секретарше или на автоответчик.) Это, наверное, не баг — автор просто не вспомнил, что бывают такие варианты.
Grawl
15.03.2017 14:36А если в БД надо хранить данные в другом формате, чем маска – тогда что?
Допустим:
В поле вводим 79247322736, видим форматированный +7 (924) 732-27-36.
В значение поля упал форматированный номер, а отправлять надо введённый.
Как решать задачу?
MikeLP
Классно. Главное не забрасывайте эту либу.
Как то раз такая штука понадобилась, я кинулся — а нормальных vanilajs масок нет без jQuery. Думал свое писать, но не было времени и ресурс того не стоил. Пришлось тащить за плагином jQuery.
staticlab
jQuery.Inputmask с недавних пор имеет "полифилл" для jQuery — /extra/dependencyLibs/inputmask.dependencyLib.js. Правда в webpack подключается всё это кривовато, но работает. jQuery не требует, я даже совместно с React его использовал (правда там тогда свои особенности появляются).