/** * Ajax Autocomplete for jQuery, version 1.2.8 * (c) 2013 Tomas Kirda * * Ajax Autocomplete for jQuery is freely distributable under the terms of an MIT-style license. * For details, see the web site: https://github.com/devbridge/jQuery-Autocomplete * */ /*jslint browser: true, white: true, plusplus: true */ /*global define, window, document, jQuery */ // Expose plugin as an AMD module if AMD loader is present: (function (factory) { 'use strict'; if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define(['jquery'], factory); } else { // Browser globals factory(jQuery); } }(function ($) { 'use strict'; var utils = (function () { return { escapeRegExChars: function (value) { return value.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); }, createNode: function (containerClass) { var div = document.createElement('div'); div.className = containerClass; div.style.position = 'absolute'; div.style.display = 'none'; return div; } }; }()), keys = { ESC: 27, TAB: 9, RETURN: 13, LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40 }; function Autocomplete(el, options) { var noop = function () { }, that = this, defaults = { autoSelectFirst: false, appendTo: 'body', serviceUrl: null, lookup: null, onSelect: null, width: 'auto', minChars: 1, maxHeight: 300, deferRequestBy: 0, params: {}, formatResult: Autocomplete.formatResult, delimiter: null, zIndex: 9999, type: 'GET', noCache: false, onSearchStart: noop, onSearchComplete: noop, onSearchError: noop, containerClass: 'autocomplete-suggestions', tabDisabled: false, dataType: 'text', currentRequest: null, lookupFilter: function (suggestion, originalQuery, queryLowerCase) { return suggestion.value.toLowerCase().indexOf(queryLowerCase) !== -1; }, paramName: 'query', transformResult: function (response) { return typeof response === 'string' ? $.parseJSON(response) : response; } }; // Shared variables: that.element = el; that.el = $(el); that.suggestions = []; that.badQueries = []; that.selectedIndex = -1; that.currentValue = that.element.value; that.intervalId = 0; that.cachedResponse = []; that.onChangeInterval = null; that.onChange = null; that.isLocal = false; that.suggestionsContainer = null; that.options = $.extend({}, defaults, options); that.classes = { selected: 'autocomplete-selected', suggestion: 'autocomplete-suggestion' }; that.hint = null; that.hintValue = ''; that.selection = null; // Initialize and set options: that.initialize(); that.setOptions(options); } Autocomplete.utils = utils; $.Autocomplete = Autocomplete; Autocomplete.formatResult = function (suggestion, currentValue) { var pattern = '(' + utils.escapeRegExChars(currentValue) + ')'; return suggestion.value.replace(new RegExp(pattern, 'gi'), '$1<\/strong>'); }; Autocomplete.prototype = { killerFn: null, initialize: function () { var that = this, suggestionSelector = '.' + that.classes.suggestion, selected = that.classes.selected, options = that.options, container; // Remove autocomplete attribute to prevent native suggestions: that.element.setAttribute('autocomplete', 'off'); that.killerFn = function (e) { if ($(e.target).closest('.' + that.options.containerClass).length === 0) { that.killSuggestions(); that.disableKillerFn(); } }; that.suggestionsContainer = Autocomplete.utils.createNode(options.containerClass); container = $(that.suggestionsContainer); container.appendTo(options.appendTo); // Only set width if it was provided: if (options.width !== 'auto') { container.width(options.width); } // Listen for mouse over event on suggestions list: container.on('mouseover.autocomplete', suggestionSelector, function () { that.activate($(this).data('index')); }); // Deselect active element when mouse leaves suggestions container: container.on('mouseout.autocomplete', function () { that.selectedIndex = -1; container.children('.' + selected).removeClass(selected); }); // Listen for click event on suggestions list: container.on('click.autocomplete', suggestionSelector, function () { that.select($(this).data('index')); }); that.fixPosition(); that.fixPositionCapture = function () { if (that.visible) { that.fixPosition(); } }; $(window).on('resize.autocomplete', that.fixPositionCapture); that.el.on('keydown.autocomplete', function (e) { that.onKeyPress(e); }); that.el.on('keyup.autocomplete', function (e) { that.onKeyUp(e); }); that.el.on('blur.autocomplete', function () { that.onBlur(); }); that.el.on('focus.autocomplete', function () { that.onFocus(); }); that.el.on('change.autocomplete', function (e) { that.onKeyUp(e); }); }, onFocus: function () { var that = this; that.fixPosition(); if (that.options.minChars <= that.el.val().length) { that.onValueChange(); } }, onBlur: function () { this.enableKillerFn(); }, setOptions: function (suppliedOptions) { var that = this, options = that.options; $.extend(options, suppliedOptions); that.isLocal = $.isArray(options.lookup); if (that.isLocal) { options.lookup = that.verifySuggestionsFormat(options.lookup); } // Adjust height, width and z-index: $(that.suggestionsContainer).css({ 'max-height': options.maxHeight + 'px', 'width': options.width + 'px', 'z-index': options.zIndex }); }, clearCache: function () { this.cachedResponse = []; this.badQueries = []; }, clear: function () { this.clearCache(); this.currentValue = ''; this.suggestions = []; }, disable: function () { var that = this; that.disabled = true; if (that.currentRequest) { that.currentRequest.abort(); } }, enable: function () { this.disabled = false; }, fixPosition: function () { var that = this, offset, styles; // Don't adjsut position if custom container has been specified: if (that.options.appendTo !== 'body') { return; } offset = that.el.offset(); styles = { top: (offset.top + that.el.outerHeight()) + 'px', left: offset.left + 'px' }; if (that.options.width === 'auto') { styles.width = (that.el.outerWidth() - 2) + 'px'; } $(that.suggestionsContainer).css(styles); }, enableKillerFn: function () { var that = this; $(document).on('click.autocomplete', that.killerFn); }, disableKillerFn: function () { var that = this; $(document).off('click.autocomplete', that.killerFn); }, killSuggestions: function () { var that = this; that.stopKillSuggestions(); that.intervalId = window.setInterval(function () { that.hide(); that.stopKillSuggestions(); }, 50); }, stopKillSuggestions: function () { window.clearInterval(this.intervalId); }, isCursorAtEnd: function () { var that = this, valLength = that.el.val().length, selectionStart = that.element.selectionStart, range; if (typeof selectionStart === 'number') { return selectionStart === valLength; } if (document.selection) { range = document.selection.createRange(); range.moveStart('character', -valLength); return valLength === range.text.length; } return true; }, onKeyPress: function (e) { var that = this; // If suggestions are hidden and user presses arrow down, display suggestions: if (!that.disabled && !that.visible && e.which === keys.DOWN && that.currentValue) { that.suggest(); return; } if (that.disabled || !that.visible) { return; } switch (e.which) { case keys.ESC: that.el.val(that.currentValue); that.hide(); break; case keys.RIGHT: if (that.hint && that.options.onHint && that.isCursorAtEnd()) { that.selectHint(); break; } return; case keys.TAB: if (that.hint && that.options.onHint) { that.selectHint(); return; } // Fall through to RETURN case keys.RETURN: if (that.selectedIndex === -1) { that.hide(); return; } that.select(that.selectedIndex); if (e.which === keys.TAB && that.options.tabDisabled === false) { return; } break; case keys.UP: that.moveUp(); break; case keys.DOWN: that.moveDown(); break; default: return; } // Cancel event if function did not return: e.stopImmediatePropagation(); e.preventDefault(); }, onKeyUp: function (e) { var that = this; if (that.disabled) { return; } switch (e.which) { case keys.UP: case keys.DOWN: return; } clearInterval(that.onChangeInterval); if (that.currentValue !== that.el.val()) { that.findBestHint(); if (that.options.deferRequestBy > 0) { // Defer lookup in case when value changes very quickly: that.onChangeInterval = setInterval(function () { that.onValueChange(); }, that.options.deferRequestBy); } else { that.onValueChange(); } } }, onValueChange: function () { var that = this, q; if (that.selection) { that.selection = null; (that.options.onInvalidateSelection || $.noop)(); } clearInterval(that.onChangeInterval); that.currentValue = that.el.val(); q = that.getQuery(that.currentValue); that.selectedIndex = -1; if (q.length < that.options.minChars) { that.hide(); } else { that.getSuggestions(q); } }, getQuery: function (value) { var delimiter = this.options.delimiter, parts; if (!delimiter) { return $.trim(value); } parts = value.split(delimiter); return $.trim(parts[parts.length - 1]); }, getSuggestionsLocal: function (query) { var that = this, queryLowerCase = query.toLowerCase(), filter = that.options.lookupFilter; return { suggestions: $.grep(that.options.lookup, function (suggestion) { return filter(suggestion, query, queryLowerCase); }) }; }, getSuggestions: function (q) { var response, that = this, options = that.options, serviceUrl = options.serviceUrl; response = that.isLocal ? that.getSuggestionsLocal(q) : that.cachedResponse[q]; if (response && $.isArray(response.suggestions)) { that.suggestions = response.suggestions; that.suggest(); } else if (!that.isBadQuery(q)) { options.params[options.paramName] = q; if (options.onSearchStart.call(that.element, options.params) === false) { return; } if ($.isFunction(options.serviceUrl)) { serviceUrl = options.serviceUrl.call(that.element, q); } if (that.currentRequest) { that.currentRequest.abort(); } that.currentRequest = $.ajax({ url: serviceUrl, data: options.ignoreParams ? null : options.params, type: options.type, dataType: options.dataType }).done(function (data) { that.currentRequest = null; that.processResponse(data, q); options.onSearchComplete.call(that.element, q); }).fail(function (jqXHR, textStatus, errorThrown) { options.onSearchError.call(that.element, q, jqXHR, textStatus, errorThrown); }); } }, isBadQuery: function (q) { var badQueries = this.badQueries, i = badQueries.length; while (i--) { if (q.indexOf(badQueries[i]) === 0) { return true; } } return false; }, hide: function () { var that = this; that.visible = false; that.selectedIndex = -1; $(that.suggestionsContainer).hide(); that.signalHint(null); }, suggest: function () { if (this.suggestions.length === 0) { this.hide(); return; } var that = this, formatResult = that.options.formatResult, value = that.getQuery(that.currentValue), className = that.classes.suggestion, classSelected = that.classes.selected, container = $(that.suggestionsContainer), beforeRender = that.options.beforeRender, html = '', width; // Build suggestions inner HTML: $.each(that.suggestions, function (i, suggestion) { html += '
' + formatResult(suggestion, value) + '
'; }); // If width is auto, adjust width before displaying suggestions, // because if instance was created before input had width, it will be zero. // Also it adjusts if input width has changed. // -2px to account for suggestions border. if (that.options.width === 'auto') { width = that.el.outerWidth() - 2; container.width(width > 0 ? width : 300); } container.html(html); // Select first value by default: if (that.options.autoSelectFirst) { that.selectedIndex = 0; container.children().first().addClass(classSelected); } if ($.isFunction(beforeRender)) { beforeRender.call(that.element, container); } container.show(); that.visible = true; that.findBestHint(); }, findBestHint: function () { var that = this, value = that.el.val().toLowerCase(), bestMatch = null; if (!value) { return; } $.each(that.suggestions, function (i, suggestion) { var foundMatch = suggestion.value.toLowerCase().indexOf(value) === 0; if (foundMatch) { bestMatch = suggestion; } return !foundMatch; }); that.signalHint(bestMatch); }, signalHint: function (suggestion) { var hintValue = '', that = this; if (suggestion) { hintValue = that.currentValue + suggestion.value.substr(that.currentValue.length); } if (that.hintValue !== hintValue) { that.hintValue = hintValue; that.hint = suggestion; (this.options.onHint || $.noop)(hintValue); } }, verifySuggestionsFormat: function (suggestions) { // If suggestions is string array, convert them to supported format: if (suggestions.length && typeof suggestions[0] === 'string') { return $.map(suggestions, function (value) { return { value: value, data: null }; }); } return suggestions; }, processResponse: function (response, originalQuery) { var that = this, options = that.options, result = options.transformResult(response, originalQuery); result.suggestions = that.verifySuggestionsFormat(result.suggestions); // Cache results if cache is not disabled: if (!options.noCache) { that.cachedResponse[result[options.paramName]] = result; if (result.suggestions.length === 0) { that.badQueries.push(result[options.paramName]); } } // Display suggestions only if returned query matches current value: if (originalQuery === that.getQuery(that.currentValue)) { that.suggestions = result.suggestions; that.suggest(); } }, activate: function (index) { var that = this, activeItem, selected = that.classes.selected, container = $(that.suggestionsContainer), children = container.children(); container.children('.' + selected).removeClass(selected); that.selectedIndex = index; if (that.selectedIndex !== -1 && children.length > that.selectedIndex) { activeItem = children.get(that.selectedIndex); $(activeItem).addClass(selected); return activeItem; } return null; }, selectHint: function () { var that = this, i = $.inArray(that.hint, that.suggestions); that.select(i); }, select: function (i) { var that = this; that.hide(); that.onSelect(i); }, moveUp: function () { var that = this; if (that.selectedIndex === -1) { return; } if (that.selectedIndex === 0) { $(that.suggestionsContainer).children().first().removeClass(that.classes.selected); that.selectedIndex = -1; that.el.val(that.currentValue); that.findBestHint(); return; } that.adjustScroll(that.selectedIndex - 1); }, moveDown: function () { var that = this; if (that.selectedIndex === (that.suggestions.length - 1)) { return; } that.adjustScroll(that.selectedIndex + 1); }, adjustScroll: function (index) { var that = this, activeItem = that.activate(index), offsetTop, upperBound, lowerBound, heightDelta = 25; if (!activeItem) { return; } offsetTop = activeItem.offsetTop; upperBound = $(that.suggestionsContainer).scrollTop(); lowerBound = upperBound + that.options.maxHeight - heightDelta; if (offsetTop < upperBound) { $(that.suggestionsContainer).scrollTop(offsetTop); } else if (offsetTop > lowerBound) { $(that.suggestionsContainer).scrollTop(offsetTop - that.options.maxHeight + heightDelta); } that.el.val(that.getValue(that.suggestions[index].value)); that.signalHint(null); }, onSelect: function (index) { var that = this, onSelectCallback = that.options.onSelect, suggestion = that.suggestions[index]; that.currentValue = that.getValue(suggestion.value); that.el.val(that.currentValue); that.signalHint(null); that.suggestions = []; that.selection = suggestion; if ($.isFunction(onSelectCallback)) { onSelectCallback.call(that.element, suggestion); } }, getValue: function (value) { var that = this, delimiter = that.options.delimiter, currentValue, parts; if (!delimiter) { return value; } currentValue = that.currentValue; parts = currentValue.split(delimiter); if (parts.length === 1) { return value; } return currentValue.substr(0, currentValue.length - parts[parts.length - 1].length) + value; }, dispose: function () { var that = this; that.el.off('.autocomplete').removeData('autocomplete'); that.disableKillerFn(); $(window).off('resize.autocomplete', that.fixPositionCapture); $(that.suggestionsContainer).remove(); } }; // Create chainable jQuery plugin: $.fn.autocomplete = function (options, args) { var dataKey = 'autocomplete'; // If function invoked without argument return // instance of the first matched element: if (arguments.length === 0) { return this.first().data(dataKey); } return this.each(function () { var inputElement = $(this), instance = inputElement.data(dataKey); if (typeof options === 'string') { if (instance && typeof instance[options] === 'function') { instance[options](args); } } else { // If instance already exists, destroy it: if (instance && instance.dispose) { instance.dispose(); } instance = new Autocomplete(this, options); inputElement.data(dataKey, instance); } }); }; }));