/* * SOL - Searchable Option List jQuery plugin * Version 2.0.2 * https://pbauerochse.github.io/searchable-option-list/ * * Copyright 2015, Patrick Bauerochse * * Licensed under the MIT license: * http://www.opensource.org/licenses/MIT * */ /*jslint nomen: true */ ; (function ($, window, document) { 'use strict'; // constructor var SearchableOptionList = function ($element, options) { this.$originalElement = $element; this.options = options; // allow setting options as data attribute // e.g. ') .attr('placeholder', this.config.texts.searchplaceholder); this.$noResultsItem = $('
').html(this.config.texts.noItemsAvailable).hide(); this.$loadingData = $('
').html(this.config.texts.loadingData); this.$xItemsSelected = $('
'); this.$caret = $('
').click(function (e) { self.toggle(); e.preventDefault(); return false; }); var $inputContainer = $('
').append(this.$input); this.$innerContainer = $('
').append($inputContainer).append(this.$caret); this.$selection = $('
'); this.$selectionContainer = $('
') .append(this.$noResultsItem) .append(this.$loadingData) .append(this.$selection); this.$container = $('
') .hide() .data(this.DATA_KEY, this) .append(this.$selectionContainer) .append(this.$innerContainer) .insertBefore(this.$originalElement); // add selected items display container this.$showSelectionContainer = $('
'); var $el = this.config.resultsContainer || this.$innerContainer if (this.config.resultsContainer) { this.$showSelectionContainer.appendTo($el) } else { if (this.config.showSelectionBelowList) { this.$showSelectionContainer.insertAfter($el); } else { this.$showSelectionContainer.insertBefore($el); } } // dimensions if (this.config.maxHeight) { this.$selection.css('max-height', this.config.maxHeight); } // detect inline css classes and styles var cssClassesAsString = this.$originalElement.attr('class'), cssStylesAsString = this.$originalElement.attr('style'), cssClassList = [], stylesList = []; if (cssClassesAsString && cssClassesAsString.length > 0) { cssClassList = cssClassesAsString.split(/\s+/); // apply css classes to $container for (var i = 0; i < cssClassList.length; i++) { this.$container.addClass(cssClassList[i]); } } if (cssStylesAsString && cssStylesAsString.length > 0) { stylesList = cssStylesAsString.split(/\;/); // apply css inline styles to $container for (var i = 0; i < stylesList.length; i++) { var splitted = stylesList[i].split(/\s*\:\s*/g); if (splitted.length === 2) { if (splitted[0].toLowerCase().indexOf('height') >= 0) { // height property, apply to innerContainer instead of outer this.$innerContainer.css(splitted[0].trim(), splitted[1].trim()); } else { this.$container.css(splitted[0].trim(), splitted[1].trim()); } } } } if (this.$originalElement.css('display') !== 'block') { this.$container.css('width', this._getActualCssPropertyValue(this.$originalElement, 'width')); } if ($.isFunction(this.config.events.onRendered)) { this.config.events.onRendered.call(this, this); } }, _getActualCssPropertyValue: function ($element, property) { var domElement = $element.get(0), originalDisplayProperty = $element.css('display'); // set invisible to get original width setting instead of translated to px // see https://bugzilla.mozilla.org/show_bug.cgi?id=707691#c7 $element.css('display', 'none'); if (domElement.currentStyle) { return domElement.currentStyle[property]; } else if (window.getComputedStyle) { return document.defaultView.getComputedStyle(domElement, null).getPropertyValue(property); } $element.css('display', originalDisplayProperty); return $element.css(property); }, _initializeInputEvents: function () { // form event var self = this, $form = this.$input.parents('form').first(); if ($form && $form.length === 1 && !$form.data(this.WINDOW_EVENTS_KEY)) { var resetFunction = function () { var $changedItems = []; $form.find('.sol-option input').each(function (index, item) { var $item = $(item), initialState = $item.data('sol-item').selected; if ($item.prop('checked') !== initialState) { $item .prop('checked', initialState) .trigger('sol-change', true); $changedItems.push($item); } }); if ($changedItems.length > 0 && $.isFunction(self.config.events.onChange)) { self.config.events.onChange.call(self, self, $changedItems); } }; $form.on('reset', function (event) { // unfortunately the reset event gets fired _before_ // the inputs are actually reset. The only possibility // to overcome this is to set an interval to execute // own scripts some time after the actual reset event // before fields are actually reset by the browser // needed to reset newly checked fields resetFunction.call(self); // timeout for selection after form reset // needed to reset previously checked fields setTimeout(function () { resetFunction.call(self); }, 100); }); $form.data(this.WINDOW_EVENTS_KEY, true); } // text input events this.$input .focus(function () { self.open(); }) .on('propertychange input', function (e) { var valueChanged = true; if (e.type=='propertychange') { valueChanged = e.originalEvent.propertyName.toLowerCase()=='value'; } if (valueChanged) { self._applySearchTermFilter(); } }); // keyboard navigation this.$container .on('keydown', function (e) { var keyCode = e.keyCode; // event handling for keyboard navigation // only when there are results to be shown if (!self.$noResultsItem.is(':visible')) { var $currentHighlightedOption, $nextHighlightedOption, directionValue, preventDefault = false, $allVisibleOptions = self.$selection.find('.sol-option:visible'); if (keyCode === 40 || keyCode === 38) { // arrow up or down to select an item self._setKeyBoardNavigationMode(true); $currentHighlightedOption = self.$selection.find('.sol-option.keyboard-selection'); directionValue = (keyCode === 38) ? -1 : 1; // negative for up, positive for down var indexOfNextHighlightedOption = $allVisibleOptions.index($currentHighlightedOption) + directionValue; if (indexOfNextHighlightedOption < 0) { indexOfNextHighlightedOption = $allVisibleOptions.length - 1; } else if (indexOfNextHighlightedOption >= $allVisibleOptions.length) { indexOfNextHighlightedOption = 0; } $currentHighlightedOption.removeClass('keyboard-selection'); $nextHighlightedOption = $($allVisibleOptions[indexOfNextHighlightedOption]) .addClass('keyboard-selection'); self.$selection.scrollTop(self.$selection.scrollTop() + $nextHighlightedOption.position().top); preventDefault = true; } else if (self.keyboardNavigationMode === true && keyCode === 32) { // toggle current selected item with space bar $currentHighlightedOption = self.$selection.find('.sol-option.keyboard-selection input'); $currentHighlightedOption .prop('checked', !$currentHighlightedOption.prop('checked')) .trigger('change'); preventDefault = true; } if (preventDefault) { // dont trigger any events in the input e.preventDefault(); return false; } } }) .on('keyup', function (e) { var keyCode = e.keyCode; if (keyCode === 27) { // escape key if (self.keyboardNavigationMode === true) { self._setKeyBoardNavigationMode(false); } else if (self.$input.val() === '') { // trigger closing of container self.$caret.trigger('click'); self.$input.trigger('blur'); } else { // reset input and result filter self.$input.val('').trigger('input'); } } else if (keyCode === 16 || keyCode === 17 || keyCode === 18 || keyCode === 20) { // special events like shift and control return; } }); }, _setKeyBoardNavigationMode: function (keyboardNavigationOn) { if (keyboardNavigationOn) { // on this.keyboardNavigationMode = true; this.$selection.addClass('sol-keyboard-navigation'); } else { // off this.keyboardNavigationMode = false; this.$selection.find('.sol-option.keyboard-selection') this.$selection.removeClass('sol-keyboard-navigation'); this.$selectionContainer.find('.sol-option.keyboard-selection').removeClass('keyboard-selection'); this.$selection.scrollTop(0); } }, _applySearchTermFilter: function () { if (!this.items || this.items.length === 0) { return; } var searchTerm = this.$input.val(), lowerCased = (searchTerm || '').toLowerCase(); // show previously filtered elements again this.$selectionContainer.find('.sol-filtered-search').removeClass('sol-filtered-search'); this._setNoResultsItemVisible(false); if (lowerCased.trim().length > 0) { this._findTerms(this.items, lowerCased); } // call onScroll to position the popup again // important if showing popup above list if ($.isFunction(this.config.events.onScroll)) { this.config.events.onScroll.call(this); } }, _findTerms: function (dataArray, searchTerm) { if (!dataArray || !$.isArray(dataArray) || dataArray.length === 0) { return; } var self = this; // reset keyboard navigation mode when applying new filter this._setKeyBoardNavigationMode(false); $.each(dataArray, function (index, item) { if (item.type === 'option') { var $element = item.displayElement, elementSearchableTerms = (item.label + ' ' + item.tooltip).trim().toLowerCase(); if (elementSearchableTerms.indexOf(searchTerm) === -1) { $element.addClass('sol-filtered-search'); } } else { self._findTerms(item.children, searchTerm); var amountOfUnfilteredChildren = item.displayElement.find('.sol-option:not(.sol-filtered-search)'); if (amountOfUnfilteredChildren.length === 0) { item.displayElement.addClass('sol-filtered-search'); } } }); this._setNoResultsItemVisible(this.$selectionContainer.find('.sol-option:not(.sol-filtered-search)').length === 0); }, _initializeData: function () { if (!this.config.data) { this.items = this._detectDataFromOriginalElement(); } else if ($.isFunction(this.config.data)) { this.items = this._fetchDataFromFunction(this.config.data); } else if ($.isArray(this.config.data)) { this.items = this._fetchDataFromArray(this.config.data); } else if (typeof this.config.data === (typeof 'a string')) { this._loadItemsFromUrl(this.config.data); } else { this._showErrorLabel('Unknown data type'); } if (this.items) { // done right away -> invoke postprocessing this._processDataItems(this.items); } }, _detectDataFromOriginalElement: function () { if (this.$originalElement.prop('tagName').toLowerCase() === 'select') { var self = this, solData = []; $.each(this.$originalElement.children(), function (index, item) { var $item = $(item), itemTagName = $item.prop('tagName').toLowerCase(), solDataItem; if (itemTagName === 'option') { solDataItem = self._processSelectOption($item); if (solDataItem) { solData.push(solDataItem); } } else if (itemTagName === 'optgroup') { solDataItem = self._processSelectOptgroup($item); if (solDataItem) { solData.push(solDataItem); } } else { self._showErrorLabel('Invalid element found in select: ' + itemTagName + '. Only option and optgroup are allowed'); } }); return this._invokeConverterIfNeccessary(solData); } else if (this.$originalElement.data('sol-data')) { var solDataAttributeValue = this.$originalElement.data('sol-data'); return this._invokeConverterIfNeccessary(solDataAttributeValue); } else { this._showErrorLabel('Could not determine data from original element. Must be a select or data must be provided as data-sol-data="" attribute'); } }, _processSelectOption: function ($option) { return $.extend({}, this.SOL_OPTION_FORMAT, { value: $option.val(), selected: $option.prop('selected'), disabled: $option.prop('disabled'), cssClass: $option.attr('class'), label: $option.html(), tooltip: $option.attr('title'), element: $option }); }, _processSelectOptgroup: function ($optgroup) { var self = this, solOptiongroup = $.extend({}, this.SOL_OPTIONGROUP_FORMAT, { label: $optgroup.attr('label'), tooltip: $optgroup.attr('title'), disabled: $optgroup.prop('disabled'), children: [] }), optgroupChildren = $optgroup.children('option'); $.each(optgroupChildren, function (index, item) { var $child = $(item), solOption = self._processSelectOption($child); // explicitly disable children when optgroup is disabled if (solOptiongroup.disabled) { solOption.disabled = true; } solOptiongroup.children.push(solOption); }); return solOptiongroup; }, _fetchDataFromFunction: function (dataFunction) { return this._invokeConverterIfNeccessary(dataFunction(this)); }, _fetchDataFromArray: function (dataArray) { return this._invokeConverterIfNeccessary(dataArray); }, _loadItemsFromUrl: function (url) { var self = this; $.ajax(url, { success: function (actualData) { self.items = self._invokeConverterIfNeccessary(actualData); if (self.items) { self._processDataItems(self.items); } }, error: function (xhr, status, message) { self._showErrorLabel('Error loading from url ' + url + ': ' + message); }, dataType: 'json' }); }, _invokeConverterIfNeccessary: function (dataItems) { if ($.isFunction(this.config.converter)) { return this.config.converter.call(this, this, dataItems); } return dataItems; }, _processDataItems: function (solItems) { if (!solItems) { this._showErrorLabel('Data items not present. Maybe the converter did not return any values'); return; } if (solItems.length === 0) { this._setNoResultsItemVisible(true); this.$loadingData.remove(); return; } var self = this, nextIndex = 0, dataProcessedFunction = function () { // hide "loading data" this.$loadingData.remove(); this._initializeSelectAll(); if ($.isFunction(this.config.events.onInitialized)) { this.config.events.onInitialized.call(this, this, solItems); } }, loopFunction = function () { var currentBatch = 0, item; while (currentBatch++ < self.config.asyncBatchSize && nextIndex < solItems.length) { item = solItems[nextIndex++]; if (item.type === self.SOL_OPTION_FORMAT.type) { self._renderOption(item); } else if (item.type === self.SOL_OPTIONGROUP_FORMAT.type) { self._renderOptiongroup(item); } else { self._showErrorLabel('Invalid item type found ' + item.type); return; } } if (nextIndex >= solItems.length) { dataProcessedFunction.call(self); } else { setTimeout(loopFunction, 0); } }; // start async rendering of html elements loopFunction.call(this); }, _renderOption: function (solOption, $optionalTargetContainer) { var self = this, $actualTargetContainer = $optionalTargetContainer || this.$selection, $inputElement, $labelText = $('
') .html(solOption.label.trim().length === 0 ? ' ' : solOption.label) .addClass(solOption.cssClass), $label, $displayElement, inputName = this._getNameAttribute(); if (this.config.multiple) { // use checkboxes $inputElement = $(''); if (this.config.useBracketParameters) { inputName += '[]'; } } else { // use radio buttons $inputElement = $('') .on('change', function () { // when selected notify all others of being deselected self.$selectionContainer.find('input[type="radio"][name="' + inputName + '"]').not($(this)).trigger('sol-deselect'); }) .on('sol-deselect', function () { // remove display selection item // TODO also better show it inline instead of above or below to save space self._removeSelectionDisplayItem($(this)); }); } $inputElement .on('change', function (event, skipCallback) { $(this).trigger('sol-change', skipCallback); }) .on('sol-change', function (event, skipCallback) { self._selectionChange($(this), skipCallback); }) .data('sol-item', solOption) .prop('checked', solOption.selected) .prop('disabled', solOption.disabled) .attr('name', inputName) .val(solOption.value); $label = $('