Кастомизация форм на JavaScrip - пишем свой autocomplete
Казалось бы, куда еще один?! Но вот встала задача у меня – иметь в проекте один универсальный автокомплит, который можно навесить на input или на select, без лишнего кода подгружать данные через ajax. И при этом иметь гибкость, в плане возможности выбора пользователем нескольких значений, ввода значений, не предусмотренных в списке и прочие плюшки, настраиваемые с помощью опций. А главно, чего не хватает во многих подобных виджетах - простая и полноценная кастомизация выпадающего списка с опциями (как на картинке).
Итак, приступим к написанию, в статье будет довольно подробно описан процесс создания плагина на базе jQuery без использования widget фабрики из jQuery UI. Я назвал его Meta Input, т.к. сейчас работаю над проектом под кодовым названием Meta и в нем мне понадобился подобный виджет.

1. Постановка задачи
Выше я уже описал в общих чертах, что нам требудется от нового виджета. Теперь опишу чуть подробнее, как он должен функционировать.
- При использовании на теге input при вводе пользователем текста под полем должен появляться список подсказок (suggest), которые сопоставимы с введенным текстом. Пользователь может с помощью клавишь вверх-вниз или мышкой выбрать нужный ему вариант. Если подразумевается выбор нескольких значений (multiple), то фокус остается в поле, рядом отображается выбранное значние, введенный текст пропадает. Если выбирается одно значение, то фокус из поля пропадает.
- При использовании на теге select при фокусе на поле сразу появляется список опций для выбора, при наборе текста список фильтруется
- При использовании ajax-загрузки данных, поиск происходит на стороне сервера, виджет просто показывает полученные опции. Предусмотреть кэширование полученных данных по тексту запроса
- Также нужно предусмотреть такие возможности, как: переключение режима поиска (искать подстроку в любом месте или только с начала строки), шаблонизация вывода подсказок, передача в качестве данных массива строк или массива объектов вида ключ-значние; возможность ввода пользователем значений, которые не представленны в списке подсказок
- Виджет должен корректно инициализироваться с уже выбранными значениями (поддержка атрибутов value и selected)
2. Структура виджета
Я не настоящий сварщик фронтендщик, поэтому данный виджет может быть построен не совсем по кананом тру-джаваскрипта, но мне больше был важен результат. Первое, с чем следует определиться – базовый интерфейс виджета:
(function ( $ ) { $.fn.meta_input = function( options ) { // опции виджета по-умолчанию this.options = $.extend({ multiple: false, // выбор нескольких значений ajax: '', // url для загрузки данных через ajax-запрос match: ['name'], // поля объекта данных, в которых будет вестись поиск подстроки matchFirst: false, // режим сопоставления (с начала строки или любая подстрока) filterSame: true, // исключать уже выбранные значния из списка limit: 100, // лимит выводимых подсказок (влияет на скорость работы) inputTimeout: 400, // задержка начала поиска подстказок после нажатия пользователем клавиши select: false, // режим select (можно использовать виджет на теге input, но при этом имитировать поведении select) suggestTemplate: '{{name}}', // шаблон вывода подсказки customValues: false, // разрешать вводить собственные значения selectPlaceholder: 'Type to filter', // текст-посказка при использовании на теге select (для input используется аттрибут placeholder) data: [], // список подсказок value: null // предустановленное значение }, options ); // если виджет вызван на наборе элементов, вешаем виджет на каждый элемент отдельно if(this.length > 1) { this.each(function(){ $(this).meta_input(options); }); return this; } this._init = function() { // инициализация виджета // тут будет основная обработка опций // навешивание обрабочиков событий и т.п. }; this._resetStyle = function() { // стилизуем инпут под "невидимый" self._input. css('display', 'block'). css('border', 'none'). css('outline', 'none'). css('box-shadow', 'none'); }; this.showSuggest = function() { // отображение подсказок }; this.selectItem = function() { // выбрать текущий элемент из подсказок как значение (или введенный текст, если разрешено) }; this._addValueItem = function(value, label) { // отобразить выбранное значение }; this.removeSelected = function() { // удалить последнее выбранное значение }; this.setValue = function(value) { // установить предустановленное значение (при инициализации) }; this.getValue = function() { // получить текущее выбранное значение }; this._isItemSelected = function(item) { // проверка, что элемент из подсказок уже был выбран }; this._displayTermData = function(data) { // непосредственно отображение подсказок }; this._fixWidth = function() { // корректировка размеров элемнта виджета }; this.navigate = function(down) { // навигация по подсказам с помощью клавиатуры }; this.closeSuggest = function() { // скрыть подсказки }; this._requestTermData = function(term) { // получить данные для подсказок по введенному тексту }; this._matchItem = function(item, term) { // сопоставление элемента подсказок и введенного текста }; this._getSelectData = function(select) { // вытаскиваем набор подсказок из опций тега select }; this._getSelectValue = function(select) { // вытаскиваем выбранные значения из опций тега select }; // первоначальная инциализация виджета this._init(); return this; }; }( jQuery ));
3. Инициализация виджета
При инциализации виджета нам нужно подготовить layout (т.е. добавить нужные элементы вокруг тега, на котором вызван виджет). Здесь мы обернем тег в div-обертку, который будет родительским элементов для всей разметки виджета. Также подготовим место для отображения выбранных значений, отображения подсказок и т.п. Проще всего рассказать кодом:
this._name = this.attr('name'); // запоминаем атрибут name для дальнейшего использования // важно отметить, что в качестве this в данной области видимости мы получает jQuery обертку над тегом, на котором инициализируется виджет if(this.prop('tagName') === 'SELECT') { // если в качестве тега имеем select this.options.data = this._getSelectData(this); // получаем набор данных на основе тега if(!this.options.value) { this.options.value = this._getSelectValue(this); // если не задано значений в опциях, выбираем значения из тега на основе атрибута selected // код функций не сложен, можно посмотреть их на GitHub } if(this.prop('multiple')) { this.options.multiple = true; // устанавливаем опции на основе атрибутов } // т.к. инпута у нас нет - создаем его this._input = $( '<input type="text" name="'+ this._name +'" class="'+ this.attr('class') +'" placeholder="">' ); this.css('display', 'none').removeAttr('name'); // а сам select при этом скрываем this._input.insertAfter(this); // вставляем инпут вместо селекта this.options.select = true; // опция поведения как селект "жестко" ставиться в true this.options.customValues = false; // опция ввода кастомных значений отключается } else { this._input = this; // если получили инпут, то его сохраним для дальнейшего использования } // создаем разметку виджета var wrap = '<div class="mi-wrap"><table><tr><td class="mi-input"></td></tr></table></div>'; this._input.wrap($(wrap)); // оборачиваем в нее инпут this._wrap = this._input.closest('.mi-wrap'); // сохраняем корневой элемент разметки this.termCache = {}; // инициализируем пустой кэш запросов this.requestTimeout = null; // переменная для хранения таймаута поиска опций var self = this; // сохраняем объект в обасти видимости this._init(); // вызываем дальнейшую инициализацию // возвращаем this для возможности использования цепочки вызовов return this;
4. Обработка событий
Теперь, пожалуй самое сложное и интересное в написании подобных вещей на JavaScript - работа с событиями. Это описание непосредственно того, как наш виджет будет реагировать на те или иные действия пользователя. Переходим к реализации метода this._init():
this._init = function() { if(self.options.select) { // если задано поведение типа select self._wrap.find('.mi-input').append('<div class="dropdown">'); // добавляем "индикатор" селекта, который стилизует наш инпут под тег select self._wrap.find('.dropdown').on('click', function(e){ // обработка события нажатия на этот индикатор if(self._wrap.find('.mi-suggest').is(':visible')) { self._input.blur(); // если список подсказок раскрыт self.closeSuggest(); // скрываем его и убираем фокус из поля } else { self._input.focus(); // иначе показываем список подсказок e.stopPropagation(); // и наводим фокус на инпут e.preventDefault(); } }); self._input.on('click', function(e){ e.stopPropagation(); // запрещаем обработку клика по инпуту (дабы не конфликтовал с индикатором) e.preventDefault(); }); } self._wrap.find('tr').prepend('<td class="mi-selected">'); // добавляем в разметку ячейку для выбранных значений self._wrap.find('.mi-input').append($('<div class="mi-suggest">')); // добавляем контейнер для отображения подсказок self._resetStyle(); // сбрасываем стили у инпута (делаем его "невидимым", т.е. без границ и обводки) // предустанавливаем указанное в опциях значение self.setValue(self.options.value); // обработка ввода текста self._input.on('keypress', function(){ if(self.requestTimeout) { window.clearTimeout(self.requestTimeout); // если уже идет поиск, останавливаем его } self.requestTimeout = window.setTimeout(function(){ self.showSuggest(); // добавляем таймаут на поиск опций }, self.options.inputTimeout); }); // обработка нажатия "функциональных" клавиш self._input.on('keydown', function(event) { if(event.keyCode == 40 || event.keyCode == 38) { // клавиши вверх-вниз event.stopPropagation(); event.preventDefault(); self.navigate(event.keyCode == 40); // вызываем навигацию } else if(event.keyCode == 27) { // esc self.closeSuggest(); // по esc скрываем подсказки } else if(event.keyCode == 13) { // enter self.selectItem(); // при нажатии enter - устанавливаем выделенное или введенное значение в качестве выбранного } else if(event.keyCode == 8) { // backspace if(!$(this).val()) { self.removeSelected(); // при нажатии backspace (если нет введенного текста) удобно для пользователя, если можно удалить последнее выбранное значение } if($(this).val().length <= 1 && !self.options.select) { self.closeSuggest(); // иначе, если удален последний символ из инпута - скрываем подсказки } if($(this).val().length > 1) { // а если текста еще достаточно в инпуте - вызываем код показа подсказок if(self.requestTimeout) { window.clearTimeout(self.requestTimeout); } self.requestTimeout = window.setTimeout(function(){ self.showSuggest(); }, self.options.inputTimeout); } } }); // обработка фокуса на поле self._input.on('focus', function(e){ if(self.options.select) { // если мы в режиме селекта, показываем плейсхолдер $(this).attr('placeholder', self.options.selectPlaceholder); self.showSuggest(); // и сразу показываем опции выбора e.stopPropagation(); e.preventDefault(); } }); // обработка наведения мыши на подсказку self._wrap.on('mouseover', '.mi-si', function(e) { self._wrap.find('.mi-si').removeClass('active'); $(this).addClass('active'); // по наведении мыши на подксказку, просто отмечаем ее как активную }); // обработка клика на подсказке self._wrap.on('click', '.mi-si', function(e) { self.selectItem(); // выбираем текущую активную подсказку if(self.options.multiple) { self._input.focus(); // если еще можно выбирать значения, то возвращаем фокус в поле } }); // функционал удаления уже выбранных значений self._wrap.on('click', '.mi-sg-rm', function(e) { $(this).closest('.mi-sg').remove(); self._input.focus(); self._fixWidth(); }); // закрытие подсказок при клике за пределами виджета $('body').on('click', function(e){ self.closeSuggest(); }); };
5. Функционал поиска, выбора и навигации
Дело осталось за малым, описать процесс поиска подсказок, их отображения, навигации по ним, а также выбор и удаление значений:
this.showSuggest = function() { var term = self._input.val().toLocaleLowerCase(); // берем введенных текст if(term || self.options.select) { // если есть текст или мы в режиме селекта if(!self.termCache[term]) { // если нет кэшированного результата запроса self._requestTermData(term); // выполняем запрос на поиск подсказок } else { self._displayTermData(self.termCache[term]); // иначе ображаем еще имеющиеся подксказки } } }; this.selectItem = function() { var current = self._wrap.find('.mi-si.active'); // ищем выделенную подсказку if(current.length) { // если такая есть if(!self.options.multiple) { self.removeSelected(); // если не разрешено несколько значений, удаляем уже выбранное значение } self._addValueItem(current.data('id'), current.data('label')); // добавляем активный элемент в выбранные значения self._input.val(''); // очищаем ввод self.closeSuggest(); // закрываем подсказки if(!self.options.multiple) { self._input.blur(); // если больше нельзя выбрать значения, убираем фокус из поля } if(self.options.select) { self._input.attr('placeholder', ''); // если в режиме селекта, скроем плейсхолдер } } else if(self.options.customValues) { // если нет активной подсказки, но разрешены кастомные значения if(self._input.val()) { // если что-то введено if(!self.options.multiple) { self.removeSelected(); // соблюдаем multiple } self._addValueItem(self._input.val(), self._input.val()); // выбираем кастомные элемент как значение self._input.val(''); // тут тоже самое, что выше self.closeSuggest(); if(!self.options.multiple) { self._input.blur(); } } } }; this._addValueItem = function(value, label) { var vi = $( '<div class="mi-sg"><div class="mi-sg-label">'+ label +'</div><div class="mi-sg-rm">×</div>' + '<input type="hidden" name="'+ self._name +'" value="'+ value +'"></div>' ); // создаем лейаут для отображения выбранного значения self._wrap.find('.mi-selected').append(vi); // добавляем созданный html в контейнер для выбранных значений self._fixWidth(); // вызываем "исправление" размеров элементов виджета (при выборе значений, наш инпут необдимо сдвигать, соотвественно нужно сдвигать и менять размеры блока подсказок и т.п.) }; this.removeSelected = function() { self._wrap.find('.mi-sg:last').remove(); // удаляем послдний выбранных элемент self._fixWidth(); // "фиксируем" размеры }; this.setValue = function(value) { self.options.value = value ? value : self.options.value; // устанавливаем значение if(!self.options.value) { self.options.value = self._input.val(); // если нет значения, попробудем взять его из тега } self._input.val(''); // убираем значение и тега (чтобы не мешалось) if(self.options.value) { // если есть что устанавливать if(Array.isArray(self.options.value)) { // если это массив значение (обычно multiple) $.each(self.options.value, function(i) { var valItem = self.options.value[i]; if(typeof valItem === 'string') { // если строка self._addValueItem(valItem, valItem); // добавляем выбранных элемент как строку } else { self._addValueItem(valItem.id, valItem.name); // иначе добавляем элемент как объект } }); } else { // если не массив, то делаем тоже самое, только один раз if(typeof self.options.value === 'string') { self._addValueItem(self.options.value, self.options.value); } else { self._addValueItem(self.options.value.id, self.options.value.name); } } } }; this.getValue = function() { if(self.options.multiple) { // если значений несколько var values = []; // будем возвращать массив self._wrap.find('.mi-selected').find('input[type=hidden]').each(function(){ values.push($(this).val()); // проходим по выбранным значениям и добавляем их в массив }); return values; } else { // если значение одно, то посто находим выбранный элемент и берем его значение return self._wrap.find('.mi-selected').find('input[type=hidden]').val(); } }; this._isItemSelected = function(item) { var same = !self.options.filterSame; // флаг необходимости проверки на уже выбранные элементы if(self.options.filterSame) { // если проверка нужна var value = self.getValue(); // получаем уже выбранные значения if(value) { // сравниваем в зависимости от того, значениу у нас массив или ключ same = (self.options.multiple) ? value.indexOf(item.id) !== -1 : value == item.id; } } return same; }; this._displayTermData = function(data) { var suggest = $('<div class="mi-suggest-items">'); // контейнер для отображения подсказок $.each(data, function(i) { var item = data[i]; // обходим массив данных для отображения if(!self._isItemSelected(item)) { // проверяем, что элемент еще не выбран // формируем базовый элемент подсказки var el = $('<div class="mi-si" data-id="' + item.id + '" data-label="' + item.name + '"></div>'); var tpl = self.options.suggestTemplate; // берем шаблон вывода саггеста for (var key in item) { // и выполняем "рендер" шаблона if (item.hasOwnProperty(key)) { tpl = tpl.replace('{{' + key + '}}', item[key]); } } el.html(tpl); // соединяем шаблон с базовым элементом suggest.append(el); // добавляем все этого в контейнер } }); if(data) { // если есть что показывать self._wrap.find('.mi-suggest').html(suggest).show(); // показываем контейнер self._fixWidth(); // "фиксируем" ширину if(!self.options.customValues) { self.navigate(1); // если у нас не разрешено собственных значение сразу делаем первый элемент подксказок активным - это удобно для быстрого ввода значений (ввели пару букв - видим что первая подсказка подходящая, жмем enter и все!) } } }; this._fixWidth = function() { var col1 = 0; // та самая загадочная "фиксация" размеров var maxCol1 = (self._wrap.width() / 3) * 2; // берем максимальный размер колонки с выбранными значениями как 2/3 от всей ширины виджета self._wrap.find('.mi-sg').each(function(){ col1 += $(this).width() + 5; // считаем ширину всех выбранных значений }); if(col1 > maxCol1) { col1 = maxCol1; // определяем ширину колонки с выбранными значениями } self._wrap.find('.mi-selected').css('width', col1 + 'px'); // применяем эту ширину var width = self._input.width() + 24; // высчитываем ширину контейнера для подсказок self._wrap.find('.mi-suggest').css('width', width + 'px'); // и применяем ее }; this.navigate = function(down) { var current = self._wrap.find('.mi-si.active'); // для навигации сначала ищем текущий активный элемент var select = null; // это будет элемент, который надо сделать активным в результате навигации if(down) { // если навигируем вниз select = current.length ? current.next('.mi-si') : self._wrap.find('.mi-si:first'); // берем либо следующий за текущим элемент, либо просто первый в списке подсказок } else { // если вверх select = current.length ? current.prev('.mi-si') : self._wrap.find('.mi-si:last'); // то соответственно берем предыдущий или последний } self._wrap.find('.mi-si').removeClass('active'); // убираем отметку с текущего активного элемента select.addClass('active'); // новый делаем активным if(select.length) { // важная штука, если подсказок много, то при навигации клавиатурой может оказаться, что активный элемент вне зоне видимости var suggest = self._wrap.find('.mi-suggest'); // чтобы избежать этого берем контейнер с подсказками suggest.scrollTop(suggest.scrollTop() + select.position().top); // и скролим его до активного элемента } }; this.closeSuggest = function() { self._wrap.find('.mi-suggest').html('').hide(); // тут все просто }; this._requestTermData = function(term) { if(self.options.ajax) { // если задан поиск через ajax $.getJSON(self.options.ajax, {term: term}, function(json) { if(json && json.data) { // получаем данные с сервера self.termCache[term] = json.data; // кэшируем результат self._displayTermData(json.data); // и отображем их без лишний манипуляций } }); } else { // иначе будем искать сами по массиву var found = 0; // счетчик найденных данных var data = []; // найденные данные for(var i = 0; i < self.options.data.length; i++) { var item = self.options.data[i]; if(!term || self._matchItem(item, term)) { // если элемент подходит по запрос data.push(typeof item === 'string' ? {id:item, name:item} : item); // добавляем его в набор (всегда как объект) found++; if(found >= self.options.limit) { break; // если вышли за лимит, стопаем поиск } } } self.termCache[term] = data; // кэшируем результат self._displayTermData(data); // отображаем то, что нашли } }; this._matchItem = function(item, term) { if(typeof item === 'string') { // если элемент это строка var idx = item.toLocaleLowerCase().indexOf(term); // ищем вхождение подстроки if( (self.options.matchFirst && idx === 0) || (!self.options.matchFirst && idx !== -1) ) { return true; // опеределяем совпадение в зависимости от режима поиска по вхождению запроса с начала строки или в любом месте } } else { var matched = false; // если нужно сопоставить текст с объетом, то будем сопоставлять строку с теми свойстами объекта, которые заданы в опции match $.each(this.options.match, function(k, e) { var idx = item[e] ? item[e].toLocaleLowerCase().indexOf(term) : -1; // далее уже тоже самое, что со строкой if( (self.options.matchFirst && idx === 0) || (!self.options.matchFirst && idx !== -1) ) { matched = true; // элемент удовлетворяет условиям, если хотя бы одно его свойство сопоставилось строке } }); return matched; } };
В скором будущем планирую добавить в виджет функционал callback'ов – для возможности реагировать на определенные события: прежде всего выбор значения. Также необходимо будет добавить API, чтобы была возможность динамически задавать значение полю и получать его.