Кастомизация форм на JavaScript – пишем свой autocomplete

2015-06-09 в 01:13 JavaScript jQuery Widget Projects

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

Итак, приступим к написанию, в статье будет довольно подробно описан процесс создания плагина на базе jQuery без использования widget фабрики из jQuery UI. Я назвал его Meta Input, т.к. сейчас работаю над проектом под кодовым названием Meta и в нем мне понадобился подобный виджет.

Meta Input



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, чтобы была возможность динамически задавать значение полю и получать его.

На этом, вроде бы и все. Вполне можно тестировать виджет. Для демонстрации его работы я набросал небольшую страничку с несколькими примерами с их довольно подробным описанием:



Автор   

Комментарии (2)    написать


Алексей

Любопытно, а как вывести для пользователей форму? )

 Ответить   


Алексей

сорян, тупанул

 Ответить   



Написать комментарий:

Чтобы комментрировать укажите свои данные или войдите через один из социальных сервисов

Загрузка...