2 * @fileOverview jquery-autocomplete, the jQuery Autocompleter
3 * @author <a href="mailto:dylan@dyve.net">Dylan Verheul</a>
5 * @requires jQuery 1.6+
6 * @license MIT | GPL | Apache 2.0, see LICENSE.txt
7 * @see https://github.com/dyve/jquery-autocomplete
13 * jQuery autocomplete plugin
14 * @param {object|string} options
15 * @returns (object} jQuery object
17 $.fn.autocomplete = function(options) {
19 if (arguments.length > 1) {
21 options = arguments[1];
23 } else if (typeof options === 'string') {
25 options = { url: url };
27 var opts = $.extend({}, $.fn.autocomplete.defaults, options);
28 return this.each(function() {
30 $this.data('autocompleter', new $.Autocompleter(
32 $.meta ? $.extend({}, opts, $this.data()) : opts
38 * Store default options
41 $.fn.autocomplete.defaults = {
42 inputClass: 'acInput',
43 loadingClass: 'acLoading',
44 resultsClass: 'acResults',
45 selectClass: 'acSelect',
48 remoteDataType: false,
63 preventDefaultReturn: 1,
73 matchStringConverter: null,
74 beforeUseConverter: null,
75 autoWidth: 'min-width',
78 delimiterKeyCode: 188,
86 * @param {Object} result
87 * @returns {Object} object with members value (String) and data (Object)
90 var sanitizeResult = function(result) {
92 var type = typeof result;
93 if (type === 'string') {
96 } else if ($.isArray(result)) {
98 data = result.slice(1);
99 } else if (type === 'object') {
100 value = result.value;
103 value = String(value);
104 if (typeof data !== 'object') {
115 * @param {mixed} value
116 * @param {Object} options
117 * @returns {Number} integer
120 var sanitizeInteger = function(value, stdValue, options) {
121 var num = parseInt(value, 10);
122 options = options || {};
123 if (isNaN(num) || (options.min && num < options.min)) {
130 * Create partial url for a name/value pair
132 var makeUrlParam = function(name, value) {
133 return [name, encodeURIComponent(value)].join('=');
138 * @param {string} url Base url
139 * @param {object} [params] Dictionary of parameters
141 var makeUrl = function(url, params) {
143 $.each(params, function(index, value) {
144 urlAppend.push(makeUrlParam(index, value));
146 if (urlAppend.length) {
147 url += url.indexOf('?') === -1 ? '?' : '&';
148 url += urlAppend.join('&');
154 * Default sort filter
157 * @param {boolean} matchCase
160 var sortValueAlpha = function(a, b, matchCase) {
177 * Parse data received in text format
178 * @param {string} text Plain text input
179 * @param {string} lineSeparator String that separates lines
180 * @param {string} cellSeparator String that separates cells
181 * @returns {array} Array of autocomplete data objects
183 var plainTextParser = function(text, lineSeparator, cellSeparator) {
185 var i, j, data, line, value, lines;
186 // Be nice, fix linebreaks before splitting on lineSeparator
187 lines = String(text).replace('\r\n', '\n').split(lineSeparator);
188 for (i = 0; i < lines.length; i++) {
189 line = lines[i].split(cellSeparator);
191 for (j = 0; j < line.length; j++) {
192 data.push(decodeURIComponent(line[j]));
194 value = data.shift();
195 results.push({ value: value, data: data });
201 * Autocompleter class
202 * @param {object} $elem jQuery object with one input tag
203 * @param {object} options Settings
206 $.Autocompleter = function($elem, options) {
211 if (!$elem || !($elem instanceof $) || $elem.length !== 1 || $elem.get(0).tagName.toUpperCase() !== 'INPUT') {
212 throw new Error('Invalid parameter for jquery.Autocompleter, jQuery object with one element with INPUT tag expected.');
216 * @constant Link to this instance
223 * @property {object} Options for this instance
226 this.options = options;
229 * @property object Cached data for this instance
232 this.cacheData_ = {};
235 * @property {number} Number of cached data items
238 this.cacheLength_ = 0;
241 * @property {string} Class name to mark selected item
244 this.selectClass_ = 'jquery-autocomplete-selected-item';
247 * @property {number} Handler to activation timeout
250 this.keyTimeout_ = null;
253 * @property {number} Handler to finish timeout
256 this.finishTimeout_ = null;
259 * @property {number} Last key pressed in the input field (store for behavior)
262 this.lastKeyPressed_ = null;
265 * @property {string} Last value processed by the autocompleter
268 this.lastProcessedValue_ = null;
271 * @property {string} Last value selected by the user
274 this.lastSelectedValue_ = null;
277 * @property {boolean} Is this autocompleter active (showing results)?
281 this.active_ = false;
284 * @property {boolean} Is this autocompleter allowed to finish on blur?
287 this.finishOnBlur_ = true;
292 this.options.minChars = sanitizeInteger(this.options.minChars, $.fn.autocomplete.defaults.minChars, { min: 0 });
293 this.options.maxItemsToShow = sanitizeInteger(this.options.maxItemsToShow, $.fn.autocomplete.defaults.maxItemsToShow, { min: 0 });
294 this.options.maxCacheLength = sanitizeInteger(this.options.maxCacheLength, $.fn.autocomplete.defaults.maxCacheLength, { min: 1 });
295 this.options.delay = sanitizeInteger(this.options.delay, $.fn.autocomplete.defaults.delay, { min: 0 });
296 if (this.options.preventDefaultReturn != 2) {
297 this.options.preventDefaultReturn = this.options.preventDefaultReturn ? 1 : 0;
299 if (this.options.preventDefaultTab != 2) {
300 this.options.preventDefaultTab = this.options.preventDefaultTab ? 1 : 0;
304 * Init DOM elements repository
309 * Store the input element we're attached to in the repository
311 this.dom.$elem = $elem;
314 * Switch off the native autocomplete and add the input class
316 this.dom.$elem.attr('autocomplete', 'off').addClass(this.options.inputClass);
319 * Create DOM element to hold results, and force absolute position
321 this.dom.$results = $('<div></div>').hide().addClass(this.options.resultsClass).css({
324 $('body').append(this.dom.$results);
327 * Attach keyboard monitoring to $elem
329 $elem.keydown(function(e) {
330 self.lastKeyPressed_ = e.keyCode;
331 switch(self.lastKeyPressed_) {
333 case self.options.delimiterKeyCode: // comma = 188
334 if (self.options.useDelimiter && self.active_) {
335 self.selectCurrent();
339 // ignore navigational & special keys
369 self.selectCurrent();
370 if (self.options.preventDefaultTab) {
375 if (self.options.preventDefaultTab === 2) {
383 self.selectCurrent();
384 if (self.options.preventDefaultReturn) {
389 if (self.options.preventDefaultReturn === 2) {
398 self.deactivate(true);
410 * Attach paste event listener because paste may occur much later then keydown or even without a keydown at all
412 $elem.on('paste', function() {
417 * Finish on blur event
418 * Use a timeout because instant blur gives race conditions
420 var onBlurFunction = function() {
421 self.deactivate(true);
423 $elem.blur(function() {
424 if (self.finishOnBlur_) {
425 self.finishTimeout_ = setTimeout(onBlurFunction, 200);
429 * Catch a race condition on form submit
431 $elem.parents('form').on('submit', onBlurFunction);
436 * Position output DOM elements
439 $.Autocompleter.prototype.position = function() {
440 var offset = this.dom.$elem.offset();
441 var height = this.dom.$results.outerHeight();
442 var totalHeight = $(window).outerHeight();
443 var inputBottom = offset.top + this.dom.$elem.outerHeight();
444 var bottomIfDown = inputBottom + height;
445 // Set autocomplete results at the bottom of input
446 var position = {top: inputBottom, left: offset.left};
447 if (bottomIfDown > totalHeight) {
448 // Try to set autocomplete results at the top of input
449 var topIfUp = offset.top - height;
451 position.top = topIfUp;
454 this.dom.$results.css(position);
461 $.Autocompleter.prototype.cacheRead = function(filter) {
462 var filterLength, searchLength, search, maxPos, pos;
463 if (this.options.useCache) {
464 filter = String(filter);
465 filterLength = filter.length;
466 if (this.options.matchSubset) {
469 searchLength = filterLength;
471 while (searchLength <= filterLength) {
472 if (this.options.matchInside) {
473 maxPos = filterLength - searchLength;
478 while (pos <= maxPos) {
479 search = filter.substr(0, searchLength);
480 if (this.cacheData_[search] !== undefined) {
481 return this.cacheData_[search];
495 $.Autocompleter.prototype.cacheWrite = function(filter, data) {
496 if (this.options.useCache) {
497 if (this.cacheLength_ >= this.options.maxCacheLength) {
500 filter = String(filter);
501 if (this.cacheData_[filter] !== undefined) {
504 this.cacheData_[filter] = data;
505 return this.cacheData_[filter];
514 $.Autocompleter.prototype.cacheFlush = function() {
515 this.cacheData_ = {};
516 this.cacheLength_ = 0;
521 * Note that all called hooks are passed the autocompleter object
522 * @param {string} hook
524 * @returns Result of called hook, false if hook is undefined
526 $.Autocompleter.prototype.callHook = function(hook, data) {
527 var f = this.options[hook];
528 if (f && $.isFunction(f)) {
529 return f(data, this);
535 * Set timeout to activate autocompleter
537 $.Autocompleter.prototype.activate = function() {
538 if (!this.options.enabled) return;
540 if (this.keyTimeout_) {
541 clearTimeout(this.keyTimeout_);
543 this.keyTimeout_ = setTimeout(function() {
545 }, this.options.delay);
549 * Activate autocompleter immediately
551 $.Autocompleter.prototype.activateNow = function() {
552 var value = this.beforeUseConverter(this.dom.$elem.val());
553 if (value !== this.lastProcessedValue_ && value !== this.lastSelectedValue_) {
554 this.fetchData(value);
559 * Get autocomplete data for a given value
560 * @param {string} value Value to base autocompletion on
563 $.Autocompleter.prototype.fetchData = function(value) {
565 var processResults = function(results, filter) {
566 if (self.options.processData) {
567 results = self.options.processData(results);
569 self.showResults(self.filterResults(results, filter), filter);
571 this.lastProcessedValue_ = value;
572 if (value.length < this.options.minChars) {
573 processResults([], value);
574 } else if (this.options.data) {
575 processResults(this.options.data, value);
577 this.fetchRemoteData(value, function(remoteData) {
578 processResults(remoteData, value);
584 * Get remote autocomplete data for a given value
585 * @param {string} filter The filter to base remote data on
586 * @param {function} callback The function to call after data retrieval
589 $.Autocompleter.prototype.fetchRemoteData = function(filter, callback) {
590 var data = this.cacheRead(filter);
595 var dataType = self.options.remoteDataType === 'json' ? 'json' : 'text';
596 var ajaxCallback = function(data) {
598 if (data !== false) {
599 parsed = self.parseRemoteData(data);
600 self.cacheWrite(filter, parsed);
602 self.dom.$elem.removeClass(self.options.loadingClass);
605 this.dom.$elem.addClass(this.options.loadingClass);
607 url: this.makeUrl(filter),
608 success: ajaxCallback,
609 error: function(jqXHR, textStatus, errorThrown) {
610 if($.isFunction(self.options.onError)) {
611 self.options.onError(jqXHR, textStatus, errorThrown);
622 * Create or update an extra parameter for the remote request
623 * @param {string} name Parameter name
624 * @param {string} value Parameter value
627 $.Autocompleter.prototype.setExtraParam = function(name, value) {
628 var index = $.trim(String(name));
630 if (!this.options.extraParams) {
631 this.options.extraParams = {};
633 if (this.options.extraParams[index] !== value) {
634 this.options.extraParams[index] = value;
643 * Build the url for a remote request
644 * If options.queryParamName === false, append query to url instead of using a GET parameter
645 * @param {string} param The value parameter to pass to the backend
646 * @returns {string} The finished url with parameters
648 $.Autocompleter.prototype.makeUrl = function(param) {
650 var url = this.options.url;
651 var params = $.extend({}, this.options.extraParams);
653 if (this.options.queryParamName === false) {
654 url += encodeURIComponent(param);
656 params[this.options.queryParamName] = param;
659 return makeUrl(url, params);
663 * Parse data received from server
664 * @param remoteData Data received from remote server
665 * @returns {array} Parsed data
667 $.Autocompleter.prototype.parseRemoteData = function(remoteData) {
669 var data = remoteData;
670 if (this.options.remoteDataType === 'json') {
671 remoteDataType = typeof(remoteData);
672 switch (remoteDataType) {
677 data = $.parseJSON(remoteData);
680 throw new Error("Unexpected remote data type: " + remoteDataType);
684 return plainTextParser(data, this.options.lineSeparator, this.options.cellSeparator);
688 * Default filter for results
689 * @param {Object} result
690 * @param {String} filter
691 * @returns {boolean} Include this result
694 $.Autocompleter.prototype.defaultFilter = function(result, filter) {
698 if (this.options.filterResults) {
699 var pattern = this.matchStringConverter(filter);
700 var testValue = this.matchStringConverter(result.value);
701 if (!this.options.matchCase) {
702 pattern = pattern.toLowerCase();
703 testValue = testValue.toLowerCase();
705 var patternIndex = testValue.indexOf(pattern);
706 if (this.options.matchInside) {
707 return patternIndex > -1;
709 return patternIndex === 0;
717 * @param {Object} result
718 * @param {String} filter
719 * @returns {boolean} Include this result
722 $.Autocompleter.prototype.filterResult = function(result, filter) {
724 if (this.options.filter === false) {
728 if ($.isFunction(this.options.filter)) {
729 return this.options.filter(result, filter);
732 return this.defaultFilter(result, filter);
740 $.Autocompleter.prototype.filterResults = function(results, filter) {
744 for (i = 0; i < results.length; i++) {
745 result = sanitizeResult(results[i]);
746 if (this.filterResult(result, filter)) {
747 filtered.push(result);
750 if (this.options.sortResults) {
751 filtered = this.sortResults(filtered, filter);
753 if (this.options.maxItemsToShow > 0 && this.options.maxItemsToShow < filtered.length) {
754 filtered.length = this.options.maxItemsToShow;
764 $.Autocompleter.prototype.sortResults = function(results, filter) {
766 var sortFunction = this.options.sortFunction;
767 if (!$.isFunction(sortFunction)) {
768 sortFunction = function(a, b, f) {
769 return sortValueAlpha(a, b, self.options.matchCase);
772 results.sort(function(a, b) {
773 return sortFunction(a, b, filter, self.options);
779 * Convert string before matching
784 $.Autocompleter.prototype.matchStringConverter = function(s, a, b) {
785 var converter = this.options.matchStringConverter;
786 if ($.isFunction(converter)) {
787 s = converter(s, a, b);
793 * Convert string before use
796 $.Autocompleter.prototype.beforeUseConverter = function(s) {
797 s = this.getValue(s);
798 var converter = this.options.beforeUseConverter;
799 if ($.isFunction(converter)) {
806 * Enable finish on blur event
808 $.Autocompleter.prototype.enableFinishOnBlur = function() {
809 this.finishOnBlur_ = true;
813 * Disable finish on blur event
815 $.Autocompleter.prototype.disableFinishOnBlur = function() {
816 this.finishOnBlur_ = false;
820 * Create a results item (LI element) from a result
823 $.Autocompleter.prototype.createItemFromResult = function(result) {
825 var $li = $('<li/>');
826 $li.html(this.showResult(result.value, result.data));
827 $li.data({value: result.value, data: result.data})
829 self.selectItem($li);
831 .mousedown(self.disableFinishOnBlur)
832 .mouseup(self.enableFinishOnBlur)
838 * Get all items from the results list
841 $.Autocompleter.prototype.getItems = function() {
842 return $('>ul>li', this.dom.$results);
850 $.Autocompleter.prototype.showResults = function(results, filter) {
851 var numResults = results.length;
853 var $ul = $('<ul></ul>');
854 var i, result, $li, autoWidth, first = false, $first = false;
857 for (i = 0; i < numResults; i++) {
859 $li = this.createItemFromResult(result);
861 if (first === false) {
862 first = String(result.value);
864 $li.addClass(this.options.firstItemClass);
866 if (i === numResults - 1) {
867 $li.addClass(this.options.lastItemClass);
871 this.dom.$results.html($ul).show();
873 // Always recalculate position since window size or
874 // input element location may have changed.
876 if (this.options.autoWidth) {
877 autoWidth = this.dom.$elem.outerWidth() - this.dom.$results.outerWidth() + this.dom.$results.width();
878 this.dom.$results.css(this.options.autoWidth, autoWidth);
880 this.getItems().hover(
881 function() { self.focusItem(this); },
882 function() { /* void */ }
884 if (this.autoFill(first, filter) || this.options.selectFirst || (this.options.selectOnly && numResults === 1)) {
885 this.focusItem($first);
890 this.active_ = false;
894 $.Autocompleter.prototype.showResult = function(value, data) {
895 if ($.isFunction(this.options.showResult)) {
896 return this.options.showResult(value, data);
898 return $('<p></p>').text(value).html();
902 $.Autocompleter.prototype.autoFill = function(value, filter) {
903 var lcValue, lcFilter, valueLength, filterLength;
904 if (this.options.autoFill && this.lastKeyPressed_ !== 8) {
905 lcValue = String(value).toLowerCase();
906 lcFilter = String(filter).toLowerCase();
907 valueLength = value.length;
908 filterLength = filter.length;
909 if (lcValue.substr(0, filterLength) === lcFilter) {
910 var d = this.getDelimiterOffsets();
911 var pad = d.start ? ' ' : ''; // if there is a preceding delimiter
912 this.setValue( pad + value );
913 var start = filterLength + d.start + pad.length;
914 var end = valueLength + d.start + pad.length;
915 this.selectRange(start, end);
922 $.Autocompleter.prototype.focusNext = function() {
926 $.Autocompleter.prototype.focusPrev = function() {
930 $.Autocompleter.prototype.focusMove = function(modifier) {
931 var $items = this.getItems();
932 modifier = sanitizeInteger(modifier, 0);
934 for (var i = 0; i < $items.length; i++) {
935 if ($($items[i]).hasClass(this.selectClass_)) {
936 this.focusItem(i + modifier);
944 $.Autocompleter.prototype.focusItem = function(item) {
945 var $item, $items = this.getItems();
947 $items.removeClass(this.selectClass_).removeClass(this.options.selectClass);
948 if (typeof item === 'number') {
951 } else if (item >= $items.length) {
952 item = $items.length - 1;
954 $item = $($items[item]);
959 $item.addClass(this.selectClass_).addClass(this.options.selectClass);
964 $.Autocompleter.prototype.selectCurrent = function() {
965 var $item = $('li.' + this.selectClass_, this.dom.$results);
966 if ($item.length === 1) {
967 this.selectItem($item);
969 this.deactivate(false);
973 $.Autocompleter.prototype.selectItem = function($li) {
974 var value = $li.data('value');
975 var data = $li.data('data');
976 var displayValue = this.displayValue(value, data);
977 var processedDisplayValue = this.beforeUseConverter(displayValue);
978 this.lastProcessedValue_ = processedDisplayValue;
979 this.lastSelectedValue_ = processedDisplayValue;
980 var d = this.getDelimiterOffsets();
981 var delimiter = this.options.delimiterChar;
982 var elem = this.dom.$elem;
983 var extraCaretPos = 0;
984 if ( this.options.useDelimiter ) {
985 // if there is a preceding delimiter, add a space after the delimiter
986 if ( elem.val().substring(d.start-1, d.start) == delimiter && delimiter != ' ' ) {
987 displayValue = ' ' + displayValue;
989 // if there is not already a delimiter trailing this value, add it
990 if ( elem.val().substring(d.end, d.end+1) != delimiter && this.lastKeyPressed_ != this.options.delimiterKeyCode ) {
991 displayValue = displayValue + delimiter;
993 // move the cursor after the existing trailing delimiter
997 this.setValue(displayValue);
998 this.setCaret(d.start + displayValue.length + extraCaretPos);
999 this.callHook('onItemSelect', { value: value, data: data });
1000 this.deactivate(true);
1004 $.Autocompleter.prototype.displayValue = function(value, data) {
1005 if ($.isFunction(this.options.displayValue)) {
1006 return this.options.displayValue(value, data);
1011 $.Autocompleter.prototype.hideResults = function() {
1012 this.dom.$results.hide();
1015 $.Autocompleter.prototype.deactivate = function(finish) {
1016 if (this.finishTimeout_) {
1017 clearTimeout(this.finishTimeout_);
1019 if (this.keyTimeout_) {
1020 clearTimeout(this.keyTimeout_);
1023 if (this.lastProcessedValue_ !== this.lastSelectedValue_) {
1024 if (this.options.mustMatch) {
1027 this.callHook('onNoMatch');
1030 this.callHook('onFinish');
1032 this.lastKeyPressed_ = null;
1033 this.lastProcessedValue_ = null;
1034 this.lastSelectedValue_ = null;
1035 this.active_ = false;
1040 $.Autocompleter.prototype.selectRange = function(start, end) {
1041 var input = this.dom.$elem.get(0);
1042 if (input.setSelectionRange) {
1044 input.setSelectionRange(start, end);
1045 } else if (input.createTextRange) {
1046 var range = input.createTextRange();
1047 range.collapse(true);
1048 range.moveEnd('character', end);
1049 range.moveStart('character', start);
1055 * Move caret to position
1056 * @param {Number} pos
1058 $.Autocompleter.prototype.setCaret = function(pos) {
1059 this.selectRange(pos, pos);
1063 * Get caret position
1065 $.Autocompleter.prototype.getCaret = function() {
1066 var $elem = this.dom.$elem;
1067 var elem = $elem[0];
1068 var val, selection, range, start, end, stored_range;
1069 if (elem.createTextRange) { // IE
1070 selection = document.selection;
1071 if (elem.tagName.toLowerCase() != 'textarea') {
1073 range = selection.createRange().duplicate();
1074 range.moveEnd('character', val.length);
1075 if (range.text === '') {
1078 start = val.lastIndexOf(range.text);
1080 range = selection.createRange().duplicate();
1081 range.moveStart('character', -val.length);
1082 end = range.text.length;
1084 range = selection.createRange();
1085 stored_range = range.duplicate();
1086 stored_range.moveToElementText(elem);
1087 stored_range.setEndPoint('EndToEnd', range);
1088 start = stored_range.text.length - range.text.length;
1089 end = start + range.text.length;
1092 start = $elem[0].selectionStart;
1093 end = $elem[0].selectionEnd;
1102 * Set the value that is currently being autocompleted
1103 * @param {String} value
1105 $.Autocompleter.prototype.setValue = function(value) {
1106 if ( this.options.useDelimiter ) {
1107 // set the substring between the current delimiters
1108 var val = this.dom.$elem.val();
1109 var d = this.getDelimiterOffsets();
1110 var preVal = val.substring(0, d.start);
1111 var postVal = val.substring(d.end);
1112 value = preVal + value + postVal;
1114 this.dom.$elem.val(value);
1118 * Get the value currently being autocompleted
1119 * @param {String} value
1121 $.Autocompleter.prototype.getValue = function(value) {
1122 if ( this.options.useDelimiter ) {
1123 var d = this.getDelimiterOffsets();
1124 return value.substring(d.start, d.end).trim();
1131 * Get the offsets of the value currently being autocompleted
1133 $.Autocompleter.prototype.getDelimiterOffsets = function() {
1134 var val = this.dom.$elem.val();
1135 if ( this.options.useDelimiter ) {
1136 var preCaretVal = val.substring(0, this.getCaret().start);
1137 var start = preCaretVal.lastIndexOf(this.options.delimiterChar) + 1;
1138 var postCaretVal = val.substring(this.getCaret().start);
1139 var end = postCaretVal.indexOf(this.options.delimiterChar);
1140 if ( end == -1 ) end = val.length;
1141 end += this.getCaret().start;
1152 })((typeof window.jQuery == 'undefined' && typeof window.django != 'undefined')? django.jQuery : jQuery);