Кастомизация форм на JavaScript – пишем свой autocomplete
2015-06-09 в 01:13 JavaScript jQuery Widget Projects
Казалось бы, куда еще один?! Но вот встала задача у меня – иметь в проекте один универсальный автокомплит, который можно навесить на 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, чтобы была возможность динамически задавать значение полю и получать его.
На этом, вроде бы и все. Вполне можно тестировать виджет. Для демонстрации его работы я набросал небольшую страничку с несколькими примерами с их довольно подробным описанием:
Автор Yakov Akulov
Комментарии (2) написать
Алексей
Любопытно, а как вывести для пользователей форму? )
Ответить
Алексей
сорян, тупанул
Ответить
Написать комментарий: