| /* | |
| * Autocomplete - jQuery plugin 1.1pre | |
| * | |
| * Copyright (c) 2007 Dylan Verheul, Dan G. Switzer, Anjesh Tuladhar, Jörn Zaefferer | |
| * | |
| * Dual licensed under the MIT and GPL licenses: | |
| * http://www.opensource.org/licenses/mit-license.php | |
| * http://www.gnu.org/licenses/gpl.html | |
| * | |
| * Revision: $Id: jquery.autocomplete.js 5785 2008-07-12 10:37:33Z joern.zaefferer $ | |
| * | |
| */ | |
| ;(function($) { | |
| $.fn.extend({ | |
| autocomplete: function(urlOrData, options) { | |
| var isUrl = typeof urlOrData == "string"; | |
| options = $.extend({}, $.Autocompleter.defaults, { | |
| url: isUrl ? urlOrData : null, | |
| data: isUrl ? null : urlOrData, | |
| delay: isUrl ? $.Autocompleter.defaults.delay : 10, | |
| max: options && !options.scroll ? 10 : 150 | |
| }, options); | |
| // if highlight is set to false, replace it with a do-nothing function | |
| options.highlight = options.highlight || function(value) { return value; }; | |
| // if the formatMatch option is not specified, then use formatItem for backwards compatibility | |
| options.formatMatch = options.formatMatch || options.formatItem; | |
| return this.each(function() { | |
| new $.Autocompleter(this, options); | |
| }); | |
| }, | |
| result: function(handler) { | |
| return this.bind("result", handler); | |
| }, | |
| search: function(handler) { | |
| return this.trigger("search", [handler]); | |
| }, | |
| flushCache: function() { | |
| return this.trigger("flushCache"); | |
| }, | |
| setOptions: function(options){ | |
| return this.trigger("setOptions", [options]); | |
| }, | |
| unautocomplete: function() { | |
| return this.trigger("unautocomplete"); | |
| } | |
| }); | |
| $.Autocompleter = function(input, options) { | |
| var KEY = { | |
| UP: 38, | |
| DOWN: 40, | |
| DEL: 46, | |
| TAB: 9, | |
| RETURN: 13, | |
| ESC: 27, | |
| COMMA: 188, | |
| PAGEUP: 33, | |
| PAGEDOWN: 34, | |
| BACKSPACE: 8 | |
| }; | |
| // Create $ object for input element | |
| var $input = $(input).attr("autocomplete", "off").addClass(options.inputClass); | |
| var timeout; | |
| var previousValue = ""; | |
| var cache = $.Autocompleter.Cache(options); | |
| var hasFocus = 0; | |
| var lastKeyPressCode; | |
| var config = { | |
| mouseDownOnSelect: false | |
| }; | |
| var select = $.Autocompleter.Select(options, input, selectCurrent, config); | |
| var blockSubmit; | |
| // prevent form submit in opera when selecting with return key | |
| $.browser.opera && $(input.form).bind("submit.autocomplete", function() { | |
| if (blockSubmit) { | |
| blockSubmit = false; | |
| return false; | |
| } | |
| }); | |
| // only opera doesn't trigger keydown multiple times while pressed, others don't work with keypress at all | |
| $input.bind(($.browser.opera ? "keypress" : "keydown") + ".autocomplete", function(event) { | |
| // track last key pressed | |
| lastKeyPressCode = event.keyCode; | |
| switch(event.keyCode) { | |
| case KEY.UP: | |
| event.preventDefault(); | |
| if ( select.visible() ) { | |
| select.prev(); | |
| } else { | |
| onChange(0, true); | |
| } | |
| break; | |
| case KEY.DOWN: | |
| event.preventDefault(); | |
| if ( select.visible() ) { | |
| select.next(); | |
| } else { | |
| onChange(0, true); | |
| } | |
| break; | |
| case KEY.PAGEUP: | |
| event.preventDefault(); | |
| if ( select.visible() ) { | |
| select.pageUp(); | |
| } else { | |
| onChange(0, true); | |
| } | |
| break; | |
| case KEY.PAGEDOWN: | |
| event.preventDefault(); | |
| if ( select.visible() ) { | |
| select.pageDown(); | |
| } else { | |
| onChange(0, true); | |
| } | |
| break; | |
| // matches also semicolon | |
| case options.multiple && $.trim(options.multipleSeparator) == "," && KEY.COMMA: | |
| case KEY.TAB: | |
| case KEY.RETURN: | |
| if( selectCurrent() ) { | |
| // stop default to prevent a form submit, Opera needs special handling | |
| event.preventDefault(); | |
| blockSubmit = true; | |
| return false; | |
| } | |
| break; | |
| case KEY.ESC: | |
| select.hide(); | |
| break; | |
| default: | |
| clearTimeout(timeout); | |
| timeout = setTimeout(onChange, options.delay); | |
| break; | |
| } | |
| }).focus(function(){ | |
| // track whether the field has focus, we shouldn't process any | |
| // results if the field no longer has focus | |
| hasFocus++; | |
| }).blur(function() { | |
| hasFocus = 0; | |
| if (!config.mouseDownOnSelect) { | |
| hideResults(); | |
| } | |
| }).click(function() { | |
| // show select when clicking in a focused field | |
| if ( hasFocus++ > 1 && !select.visible() ) { | |
| onChange(0, true); | |
| } | |
| }).bind("search", function() { | |
| // TODO why not just specifying both arguments? | |
| var fn = (arguments.length > 1) ? arguments[1] : null; | |
| function findValueCallback(q, data) { | |
| var result; | |
| if( data && data.length ) { | |
| for (var i=0; i < data.length; i++) { | |
| if( data[i].result.toLowerCase() == q.toLowerCase() ) { | |
| result = data[i]; | |
| break; | |
| } | |
| } | |
| } | |
| if( typeof fn == "function" ) fn(result); | |
| else $input.trigger("result", result && [result.data, result.value]); | |
| } | |
| $.each(trimWords($input.val()), function(i, value) { | |
| request(value, findValueCallback, findValueCallback); | |
| }); | |
| }).bind("flushCache", function() { | |
| cache.flush(); | |
| }).bind("setOptions", function() { | |
| $.extend(options, arguments[1]); | |
| // if we've updated the data, repopulate | |
| if ( "data" in arguments[1] ) | |
| cache.populate(); | |
| }).bind("unautocomplete", function() { | |
| select.unbind(); | |
| $input.unbind(); | |
| $(input.form).unbind(".autocomplete"); | |
| }); | |
| function selectCurrent() { | |
| var selected = select.selected(); | |
| if( !selected ) | |
| return false; | |
| var v = selected.result; | |
| previousValue = v; | |
| if ( options.multiple ) { | |
| var words = trimWords($input.val()); | |
| if ( words.length > 1 ) { | |
| v = words.slice(0, words.length - 1).join( options.multipleSeparator ) + options.multipleSeparator + v; | |
| } | |
| v += options.multipleSeparator; | |
| } | |
| $input.val(v); | |
| hideResultsNow(); | |
| $input.trigger("result", [selected.data, selected.value]); | |
| return true; | |
| } | |
| function onChange(crap, skipPrevCheck) { | |
| if( lastKeyPressCode == KEY.DEL ) { | |
| select.hide(); | |
| return; | |
| } | |
| var currentValue = $input.val(); | |
| if ( !skipPrevCheck && currentValue == previousValue ) | |
| return; | |
| previousValue = currentValue; | |
| currentValue = lastWord(currentValue); | |
| if ( currentValue.length >= options.minChars) { | |
| $input.addClass(options.loadingClass); | |
| if (!options.matchCase) | |
| currentValue = currentValue.toLowerCase(); | |
| request(currentValue, receiveData, hideResultsNow); | |
| } else { | |
| stopLoading(); | |
| select.hide(); | |
| } | |
| }; | |
| function trimWords(value) { | |
| if ( !value ) { | |
| return [""]; | |
| } | |
| var words = value.split( options.multipleSeparator ); | |
| var result = []; | |
| $.each(words, function(i, value) { | |
| if ( $.trim(value) ) | |
| result[i] = $.trim(value); | |
| }); | |
| return result; | |
| } | |
| function lastWord(value) { | |
| if ( !options.multiple ) | |
| return value; | |
| var words = trimWords(value); | |
| return words[words.length - 1]; | |
| } | |
| // fills in the input box w/the first match (assumed to be the best match) | |
| // q: the term entered | |
| // sValue: the first matching result | |
| function autoFill(q, sValue){ | |
| // autofill in the complete box w/the first match as long as the user hasn't entered in more data | |
| // if the last user key pressed was backspace, don't autofill | |
| if( options.autoFill && (lastWord($input.val()).toLowerCase() == q.toLowerCase()) && lastKeyPressCode != KEY.BACKSPACE ) { | |
| // fill in the value (keep the case the user has typed) | |
| $input.val($input.val() + sValue.substring(lastWord(previousValue).length)); | |
| // select the portion of the value not typed by the user (so the next character will erase) | |
| $.Autocompleter.Selection(input, previousValue.length, previousValue.length + sValue.length); | |
| } | |
| }; | |
| function hideResults() { | |
| clearTimeout(timeout); | |
| timeout = setTimeout(hideResultsNow, 200); | |
| }; | |
| function hideResultsNow() { | |
| var wasVisible = select.visible(); | |
| select.hide(); | |
| clearTimeout(timeout); | |
| stopLoading(); | |
| if (options.mustMatch) { | |
| // call search and run callback | |
| $input.search( | |
| function (result){ | |
| // if no value found, clear the input box | |
| if( !result ) { | |
| if (options.multiple) { | |
| var words = trimWords($input.val()).slice(0, -1); | |
| $input.val( words.join(options.multipleSeparator) + (words.length ? options.multipleSeparator : "") ); | |
| } | |
| else | |
| $input.val( "" ); | |
| } | |
| } | |
| ); | |
| } | |
| if (wasVisible) | |
| // position cursor at end of input field | |
| $.Autocompleter.Selection(input, input.value.length, input.value.length); | |
| }; | |
| function receiveData(q, data) { | |
| if ( data && data.length && hasFocus ) { | |
| stopLoading(); | |
| select.display(data, q); | |
| autoFill(q, data[0].value); | |
| select.show(); | |
| } else { | |
| hideResultsNow(); | |
| } | |
| }; | |
| function request(term, success, failure) { | |
| if (!options.matchCase) | |
| term = term.toLowerCase(); | |
| var data = cache.load(term); | |
| // recieve the cached data | |
| if (data && data.length) { | |
| success(term, data); | |
| // if an AJAX url has been supplied, try loading the data now | |
| } else if( (typeof options.url == "string") && (options.url.length > 0) ){ | |
| var extraParams = { | |
| timestamp: +new Date() | |
| }; | |
| $.each(options.extraParams, function(key, param) { | |
| extraParams[key] = typeof param == "function" ? param() : param; | |
| }); | |
| $.ajax({ | |
| // try to leverage ajaxQueue plugin to abort previous requests | |
| mode: "abort", | |
| // limit abortion to this input | |
| port: "autocomplete" + input.name, | |
| dataType: options.dataType, | |
| url: options.url, | |
| data: $.extend({ | |
| q: lastWord(term), | |
| limit: options.max | |
| }, extraParams), | |
| success: function(data) { | |
| var parsed = options.parse && options.parse(data) || parse(data); | |
| cache.add(term, parsed); | |
| success(term, parsed); | |
| } | |
| }); | |
| } else { | |
| // if we have a failure, we need to empty the list -- this prevents the the [TAB] key from selecting the last successful match | |
| select.emptyList(); | |
| failure(term); | |
| } | |
| }; | |
| function parse(data) { | |
| var parsed = []; | |
| var rows = data.split("\n"); | |
| for (var i=0; i < rows.length; i++) { | |
| var row = $.trim(rows[i]); | |
| if (row) { | |
| row = row.split("|"); | |
| parsed[parsed.length] = { | |
| data: row, | |
| value: row[0], | |
| result: options.formatResult && options.formatResult(row, row[0]) || row[0] | |
| }; | |
| } | |
| } | |
| return parsed; | |
| }; | |
| function stopLoading() { | |
| $input.removeClass(options.loadingClass); | |
| }; | |
| }; | |
| $.Autocompleter.defaults = { | |
| inputClass: "ac_input", | |
| resultsClass: "ac_results", | |
| loadingClass: "ac_loading", | |
| minChars: 1, | |
| delay: 400, | |
| matchCase: false, | |
| matchSubset: true, | |
| matchContains: false, | |
| cacheLength: 10, | |
| max: 100, | |
| mustMatch: false, | |
| extraParams: {}, | |
| selectFirst: true, | |
| formatItem: function(row) { return row[0]; }, | |
| formatMatch: null, | |
| autoFill: false, | |
| width: 0, | |
| multiple: false, | |
| multipleSeparator: ", ", | |
| highlight: function(value, term) { | |
| return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term.replace(/([\^\$\(\)\[\]\{\}\*\.\+\?\|\\])/gi, "\\$1") + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "<strong>$1</strong>"); | |
| }, | |
| scroll: true, | |
| scrollHeight: 180 | |
| }; | |
| $.Autocompleter.Cache = function(options) { | |
| var data = {}; | |
| var length = 0; | |
| function matchSubset(s, sub) { | |
| if (!options.matchCase) | |
| s = s.toLowerCase(); | |
| var i = s.indexOf(sub); | |
| if (options.matchContains == "word"){ | |
| i = s.toLowerCase().search("\\b" + sub.toLowerCase()); | |
| } | |
| if (i == -1) return false; | |
| return i == 0 || options.matchContains; | |
| }; | |
| function add(q, value) { | |
| if (length > options.cacheLength){ | |
| flush(); | |
| } | |
| if (!data[q]){ | |
| length++; | |
| } | |
| data[q] = value; | |
| } | |
| function populate(){ | |
| if( !options.data ) return false; | |
| // track the matches | |
| var stMatchSets = {}, | |
| nullData = 0; | |
| // no url was specified, we need to adjust the cache length to make sure it fits the local data store | |
| if( !options.url ) options.cacheLength = 1; | |
| // track all options for minChars = 0 | |
| stMatchSets[""] = []; | |
| // loop through the array and create a lookup structure | |
| for ( var i = 0, ol = options.data.length; i < ol; i++ ) { | |
| var rawValue = options.data[i]; | |
| // if rawValue is a string, make an array otherwise just reference the array | |
| rawValue = (typeof rawValue == "string") ? [rawValue] : rawValue; | |
| var value = options.formatMatch(rawValue, i+1, options.data.length); | |
| if ( value === false ) | |
| continue; | |
| var firstChar = value.charAt(0).toLowerCase(); | |
| // if no lookup array for this character exists, look it up now | |
| if( !stMatchSets[firstChar] ) | |
| stMatchSets[firstChar] = []; | |
| // if the match is a string | |
| var row = { | |
| value: value, | |
| data: rawValue, | |
| result: options.formatResult && options.formatResult(rawValue) || value | |
| }; | |
| // push the current match into the set list | |
| stMatchSets[firstChar].push(row); | |
| // keep track of minChars zero items | |
| if ( nullData++ < options.max ) { | |
| stMatchSets[""].push(row); | |
| } | |
| }; | |
| // add the data items to the cache | |
| $.each(stMatchSets, function(i, value) { | |
| // increase the cache size | |
| options.cacheLength++; | |
| // add to the cache | |
| add(i, value); | |
| }); | |
| } | |
| // populate any existing data | |
| setTimeout(populate, 25); | |
| function flush(){ | |
| data = {}; | |
| length = 0; | |
| } | |
| return { | |
| flush: flush, | |
| add: add, | |
| populate: populate, | |
| load: function(q) { | |
| if (!options.cacheLength || !length) | |
| return null; | |
| /* | |
| * if dealing w/local data and matchContains than we must make sure | |
| * to loop through all the data collections looking for matches | |
| */ | |
| if( !options.url && options.matchContains ){ | |
| // track all matches | |
| var csub = []; | |
| // loop through all the data grids for matches | |
| for( var k in data ){ | |
| // don't search through the stMatchSets[""] (minChars: 0) cache | |
| // this prevents duplicates | |
| if( k.length > 0 ){ | |
| var c = data[k]; | |
| $.each(c, function(i, x) { | |
| // if we've got a match, add it to the array | |
| if (matchSubset(x.value, q)) { | |
| csub.push(x); | |
| } | |
| }); | |
| } | |
| } | |
| return csub; | |
| } else | |
| // if the exact item exists, use it | |
| if (data[q]){ | |
| return data[q]; | |
| } else | |
| if (options.matchSubset) { | |
| for (var i = q.length - 1; i >= options.minChars; i--) { | |
| var c = data[q.substr(0, i)]; | |
| if (c) { | |
| var csub = []; | |
| $.each(c, function(i, x) { | |
| if (matchSubset(x.value, q)) { | |
| csub[csub.length] = x; | |
| } | |
| }); | |
| return csub; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| }; | |
| }; | |
| $.Autocompleter.Select = function (options, input, select, config) { | |
| var CLASSES = { | |
| ACTIVE: "ac_over" | |
| }; | |
| var listItems, | |
| active = -1, | |
| data, | |
| term = "", | |
| needsInit = true, | |
| element, | |
| list; | |
| // Create results | |
| function init() { | |
| if (!needsInit) | |
| return; | |
| element = $("<div/>") | |
| .hide() | |
| .addClass(options.resultsClass) | |
| .css("position", "absolute") | |
| .appendTo(document.body); | |
| list = $("<ul/>").appendTo(element).mouseover( function(event) { | |
| if(target(event).nodeName && target(event).nodeName.toUpperCase() == 'LI') { | |
| active = $("li", list).removeClass(CLASSES.ACTIVE).index(target(event)); | |
| $(target(event)).addClass(CLASSES.ACTIVE); | |
| } | |
| }).click(function(event) { | |
| $(target(event)).addClass(CLASSES.ACTIVE); | |
| select(); | |
| // TODO provide option to avoid setting focus again after selection? useful for cleanup-on-focus | |
| input.focus(); | |
| return false; | |
| }).mousedown(function() { | |
| config.mouseDownOnSelect = true; | |
| }).mouseup(function() { | |
| config.mouseDownOnSelect = false; | |
| }); | |
| if( options.width > 0 ) | |
| element.css("width", options.width); | |
| needsInit = false; | |
| } | |
| function target(event) { | |
| var element = event.target; | |
| while(element && element.tagName != "LI") | |
| element = element.parentNode; | |
| // more fun with IE, sometimes event.target is empty, just ignore it then | |
| if(!element) | |
| return []; | |
| return element; | |
| } | |
| function moveSelect(step) { | |
| listItems.slice(active, active + 1).removeClass(CLASSES.ACTIVE); | |
| movePosition(step); | |
| var activeItem = listItems.slice(active, active + 1).addClass(CLASSES.ACTIVE); | |
| if(options.scroll) { | |
| var offset = 0; | |
| listItems.slice(0, active).each(function() { | |
| offset += this.offsetHeight; | |
| }); | |
| if((offset + activeItem[0].offsetHeight - list.scrollTop()) > list[0].clientHeight) { | |
| list.scrollTop(offset + activeItem[0].offsetHeight - list.innerHeight()); | |
| } else if(offset < list.scrollTop()) { | |
| list.scrollTop(offset); | |
| } | |
| } | |
| }; | |
| function movePosition(step) { | |
| active += step; | |
| if (active < 0) { | |
| active = listItems.size() - 1; | |
| } else if (active >= listItems.size()) { | |
| active = 0; | |
| } | |
| } | |
| function limitNumberOfItems(available) { | |
| return options.max && options.max < available | |
| ? options.max | |
| : available; | |
| } | |
| function fillList() { | |
| list.empty(); | |
| var max = limitNumberOfItems(data.length); | |
| for (var i=0; i < max; i++) { | |
| if (!data[i]) | |
| continue; | |
| var formatted = options.formatItem(data[i].data, i+1, max, data[i].value, term); | |
| if ( formatted === false ) | |
| continue; | |
| var li = $("<li/>").html( options.highlight(formatted, term) ).addClass(i%2 == 0 ? "ac_even" : "ac_odd").appendTo(list)[0]; | |
| $.data(li, "ac_data", data[i]); | |
| } | |
| listItems = list.find("li"); | |
| if ( options.selectFirst ) { | |
| listItems.slice(0, 1).addClass(CLASSES.ACTIVE); | |
| active = 0; | |
| } | |
| // apply bgiframe if available | |
| if ( $.fn.bgiframe ) | |
| list.bgiframe(); | |
| } | |
| return { | |
| display: function(d, q) { | |
| init(); | |
| data = d; | |
| term = q; | |
| fillList(); | |
| }, | |
| next: function() { | |
| moveSelect(1); | |
| }, | |
| prev: function() { | |
| moveSelect(-1); | |
| }, | |
| pageUp: function() { | |
| if (active != 0 && active - 8 < 0) { | |
| moveSelect( -active ); | |
| } else { | |
| moveSelect(-8); | |
| } | |
| }, | |
| pageDown: function() { | |
| if (active != listItems.size() - 1 && active + 8 > listItems.size()) { | |
| moveSelect( listItems.size() - 1 - active ); | |
| } else { | |
| moveSelect(8); | |
| } | |
| }, | |
| hide: function() { | |
| element && element.hide(); | |
| listItems && listItems.removeClass(CLASSES.ACTIVE); | |
| active = -1; | |
| }, | |
| visible : function() { | |
| return element && element.is(":visible"); | |
| }, | |
| current: function() { | |
| return this.visible() && (listItems.filter("." + CLASSES.ACTIVE)[0] || options.selectFirst && listItems[0]); | |
| }, | |
| show: function() { | |
| var offset = $(input).offset(); | |
| element.css({ | |
| width: typeof options.width == "string" || options.width > 0 ? options.width : $(input).width(), | |
| top: offset.top + input.offsetHeight, | |
| left: offset.left | |
| }).show(); | |
| if(options.scroll) { | |
| list.scrollTop(0); | |
| list.css({ | |
| maxHeight: options.scrollHeight, | |
| overflow: 'auto' | |
| }); | |
| if($.browser.msie && typeof document.body.style.maxHeight === "undefined") { | |
| var listHeight = 0; | |
| listItems.each(function() { | |
| listHeight += this.offsetHeight; | |
| }); | |
| var scrollbarsVisible = listHeight > options.scrollHeight; | |
| list.css('height', scrollbarsVisible ? options.scrollHeight : listHeight ); | |
| if (!scrollbarsVisible) { | |
| // IE doesn't recalculate width when scrollbar disappears | |
| listItems.width( list.width() - parseInt(listItems.css("padding-left")) - parseInt(listItems.css("padding-right")) ); | |
| } | |
| } | |
| } | |
| }, | |
| selected: function() { | |
| var selected = listItems && listItems.filter("." + CLASSES.ACTIVE).removeClass(CLASSES.ACTIVE); | |
| return selected && selected.length && $.data(selected[0], "ac_data"); | |
| }, | |
| emptyList: function (){ | |
| list && list.empty(); | |
| }, | |
| unbind: function() { | |
| element && element.remove(); | |
| } | |
| }; | |
| }; | |
| $.Autocompleter.Selection = function(field, start, end) { | |
| if( field.createTextRange ){ | |
| var selRange = field.createTextRange(); | |
| selRange.collapse(true); | |
| selRange.moveStart("character", start); | |
| selRange.moveEnd("character", end); | |
| selRange.select(); | |
| } else if( field.setSelectionRange ){ | |
| field.setSelectionRange(start, end); | |
| } else { | |
| if( field.selectionStart ){ | |
| field.selectionStart = start; | |
| field.selectionEnd = end; | |
| } | |
| } | |
| field.focus(); | |
| }; | |
| })(jQuery); |