/** * Ajax Autocomplete for jQuery, version dev * RhodeCode additions * (c) 2014 Tomas Kirda * (c) 2014 Marcin Kuzminski * * 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 */ // 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 if (typeof exports === 'object' && typeof require === 'function') { // Browserify factory(require('jquery')); } 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 = { ajaxSettings: {}, autoSelectFirst: false, appendTo: document.body, serviceUrl: null, lookup: null, width: 'auto', minChars: 1, maxHeight: 300, deferRequestBy: 0, params: {}, formatResult: Autocomplete.formatResult, lookupFilter: Autocomplete.lookupFilter, delimiter: null, zIndex: 9999, type: 'GET', noCache: false, onSelect: noop, onSearchStart: noop, onSearchComplete: noop, onSearchError: noop, containerClass: 'autocomplete-suggestions', tabDisabled: false, dataType: 'text', currentRequest: null, triggerSelectOnValidInput: false, preventBadQueries: true, paramName: 'query', transformResult: function (response) { return typeof response === 'string' ? $.parseJSON(response) : response; }, showNoSuggestionNotice: false, noSuggestionNotice: _gettext('No results'), orientation: 'bottom', forceFixPosition: false, replaceOnArrowKey: true }; // 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.noSuggestionsContainer = 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'), '<strong>$1<\/strong>'); }; Autocomplete.lookupFilter = function (suggestion, originalQuery, queryLowerCase) { return suggestion.value.toLowerCase().indexOf(queryLowerCase) !== -1; }; 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(); } }; // html() deals with many types: htmlString or Element or Array or jQuery that.noSuggestionsContainer = $('<div class="autocomplete-no-suggestion"></div>') .html(this.options.noSuggestionNotice).get(0); 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.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); } options.orientation = that.validateOrientation(options.orientation, 'bottom'); // 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 () { // Use only when container has already its content var that = this, $container = $(that.suggestionsContainer), containerParent = $container.parent().get(0); // Fix position automatically when appended to body. // In other cases force parameter must be given. if (containerParent !== document.body && !that.options.forceFixPosition) return; // Choose orientation var orientation = that.options.orientation, containerHeight = $container.outerHeight(), height = that.el.outerHeight(), offset = that.el.offset(), styles = { 'top': offset.top, 'left': offset.left }; if (orientation == 'auto') { var viewPortHeight = $(window).height(), scrollTop = $(window).scrollTop(), topOverflow = -scrollTop + offset.top - containerHeight, bottomOverflow = scrollTop + viewPortHeight - (offset.top + height + containerHeight); orientation = (Math.max(topOverflow, bottomOverflow) === topOverflow) ? 'top' : 'bottom'; } if (orientation === 'top') { styles.top += -containerHeight; } else { styles.top += height; } // If container is not positioned to body, // correct its position using offset parent offset if(containerParent !== document.body) { var opacity = $container.css('opacity'), parentOffsetDiff; if (!that.visible){ $container.css('opacity', 0).show(); } parentOffsetDiff = $container.offsetParent().offset(); styles.top -= parentOffsetDiff.top; styles.left -= parentOffsetDiff.left; if (!that.visible){ $container.css('opacity', opacity).hide(); } } // -2px to account for suggestions border. if (that.options.width === 'auto') { styles.width = (that.el.outerWidth() - 2) + 'px'; } $container.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, options = that.options, value = that.el.val(), query = that.getQuery(value), index; if (that.selection && that.currentValue !== query) { that.selection = null; (options.onInvalidateSelection || $.noop).call(that.element); } clearInterval(that.onChangeInterval); that.currentValue = value; that.selectedIndex = -1; // Check existing suggestion for the match before proceeding: if (options.triggerSelectOnValidInput) { index = that.findSuggestionIndex(query); if (index !== -1) { that.select(index); return; } } if (query.length < options.minChars) { that.hide(); } else { that.getSuggestions(query); } }, findSuggestionIndex: function (query) { var that = this, index = -1, queryLowerCase = query.toLowerCase(); $.each(that.suggestions, function (i, suggestion) { if (suggestion.value.toLowerCase() === queryLowerCase) { index = i; return false; } }); return index; }, getQuery: function (value) { var delimiter = this.options.delimiter, parts; if (!delimiter) { return value; } parts = value.split(delimiter); return $.trim(parts[parts.length - 1]); }, getSuggestionsLocal: function (query) { var that = this, options = that.options, queryLowerCase = query.toLowerCase(), data; // re-pack the data as it was comming from AJAX data = { suggestions: data }; return data; }, getSuggestions: function (query) { var response, that = this, options = that.options, serviceUrl = options.serviceUrl, params, cacheKey, ajaxSettings; options.params[options.paramName] = query; params = options.ignoreParams ? null : options.params; if (that.isLocal) { response = that.getSuggestionsLocal(query); } else { if ($.isFunction(serviceUrl)) { serviceUrl = serviceUrl.call(that.element, query); } cacheKey = serviceUrl + '?' + $.param(params || {}); response = that.cachedResponse[cacheKey]; } if (response && $.isArray(response.suggestions)) { that.suggestions = response.suggestions; that.suggest(); } else if (!that.isBadQuery(query)) { if (options.onSearchStart.call(that.element, options.params) === false) { return; } if (that.currentRequest) { that.currentRequest.abort(); } ajaxSettings = { url: serviceUrl, data: params, type: options.type, dataType: options.dataType }; $.extend(ajaxSettings, options.ajaxSettings); that.currentRequest = $.ajax(ajaxSettings).done(function (data) { var result; that.currentRequest = null; result = options.transformResult(data); that.processResponse(result, query, cacheKey); options.onSearchComplete.call(that.element, query, result.suggestions); }).fail(function (jqXHR, textStatus, errorThrown) { options.onSearchError.call(that.element, query, jqXHR, textStatus, errorThrown); }); } }, isBadQuery: function (q) { if (!this.options.preventBadQueries){ return false; } 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 () { var that = this, options = that.options, formatResult = options.formatResult, filterResult = options.lookupFilter, value = that.getQuery(that.currentValue), className = that.classes.suggestion, classSelected = that.classes.selected, container = $(that.suggestionsContainer), noSuggestionsContainer = $(that.noSuggestionsContainer), beforeRender = options.beforeRender, limit = parseInt(that.options.lookupLimit, 10), html = '', index; // filter and limit given results var filtered_suggestions = $.grep(that.suggestions, function (suggestion) { return filterResult(suggestion, value, value.toLowerCase(), that.element); }); if (limit && filtered_suggestions.length > limit) { filtered_suggestions = filtered_suggestions.slice(0, limit); } if (filtered_suggestions.length === 0) { this.options.showNoSuggestionNotice ? this.noSuggestions() : this.hide(); return; } if (options.triggerSelectOnValidInput) { index = that.findSuggestionIndex(value); if (index !== -1) { that.select(index); return; } } // Build suggestions inner HTML: $.each(filtered_suggestions, function (i, suggestion) { html += '<div class="' + className + '" data-index="' + i + '">' + formatResult(suggestion, value, Autocomplete.formatResult, that.element) + '</div>'; }); // set internal suggestion for INDEX pick to work correctly that.suggestions = filtered_suggestions; this.adjustContainerWidth(); noSuggestionsContainer.detach(); container.html(html); // Select first value by default: if (options.autoSelectFirst) { that.selectedIndex = 0; container.children().first().addClass(classSelected); } if ($.isFunction(beforeRender)) { beforeRender.call(that.element, container); } that.fixPosition(); container.show(); that.visible = true; that.findBestHint(); }, noSuggestions: function() { var that = this, container = $(that.suggestionsContainer), noSuggestionsContainer = $(that.noSuggestionsContainer); this.adjustContainerWidth(); // Some explicit steps. Be careful here as it easy to get // noSuggestionsContainer removed from DOM if not detached properly. noSuggestionsContainer.detach(); container.empty(); // clean suggestions if any container.append(noSuggestionsContainer); that.fixPosition(); container.show(); that.visible = true; }, adjustContainerWidth: function() { var that = this, options = that.options, width, container = $(that.suggestionsContainer); // 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 (options.width === 'auto') { width = that.el.outerWidth() - 2; container.width(width > 0 ? width : 300); } }, 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; }, validateOrientation: function(orientation, fallback) { orientation = $.trim(orientation || '').toLowerCase(); if($.inArray(orientation, ['auto', 'bottom', 'top']) === -1){ orientation = fallback; } return orientation; }, processResponse: function (result, originalQuery, cacheKey) { var that = this, options = that.options; result.suggestions = that.verifySuggestionsFormat(result.suggestions); // Cache results if cache is not disabled: if (!options.noCache) { that.cachedResponse[cacheKey] = result; if (options.preventBadQueries && result.suggestions.length === 0) { that.badQueries.push(originalQuery); } } // Return if originalQuery is not matching current query: if (originalQuery !== that.getQuery(that.currentValue)) { return; } that.suggestions = result.suggestions; that.suggest(); }, activate: function (index) { var that = this, activeItem, selected = that.classes.selected, container = $(that.suggestionsContainer), children = container.find('.' + that.classes.suggestion); container.find('.' + 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 (index) { var that = this; that.hide(); that.onSelect(index); }, 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); } if (that.options.replaceOnArrowKey) { 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); var prevElem = {'value': that.el.val(), 'caret': that.element.selectionStart} if (that.currentValue !== that.el.val()) { that.el.val(that.currentValue); } that.signalHint(null); that.suggestions = []; that.selection = suggestion; if ($.isFunction(onSelectCallback)) { onSelectCallback.call(this, that.element, suggestion, prevElem); } }, 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 = $.fn.devbridgeAutocomplete = 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); } }); }; }));