/* * jquery autocomplete plugin 1.1 * * copyright (c) 2009 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 15 2009-08-22 10:30:27z 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) { // a keypress means the input has focus // avoids issue where input had focus before the autocomplete was applied hasfocus = 1; // 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 ) { var seperator = options.multipleseparator.length; var cursorat = $(input).selection().start; var wordat, progress = 0; $.each(words, function(i, word) { progress += word.length; if (cursorat <= progress) { wordat = i; return false; } progress += seperator; }); words[wordat] = v; // todo this should set the cursor to the right position, but it gets overriden somewhere //$.autocompleter.selection(input, progress + seperator, progress + seperator); v = words.join( options.multipleseparator ); } 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 [""]; if (!options.multiple) return [$.trim(value)]; return $.map(value.split(options.multipleseparator), function(word) { return $.trim(value).length ? $.trim(word) : null; }); } function lastword(value) { if ( !options.multiple ) return value; var words = trimwords(value); if (words.length == 1) return words[0]; var cursorat = $(input).selection().start; if (cursorat == value.length) { words = trimwords(value) } else { words = trimwords(value.replace(value.substring(cursorat), "")); } 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) $(input).selection(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( "" ); $input.trigger("result", null); } } } ); } }; 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 type: options.type || 'post', 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) { var result = value; var str_term = $.trim(term).replace(/\s+/gi," "); var terms = str_term.split(/\s+/); for(var i= 0, len=terms.length; i < len; i++){ result = result.replace(new regexp("(?![^&;]+;)(?!<[^<>]*)(" + terms[i].replace(/([\^\$\(\)\[\]\{\}\*\.\+\?\|\\])/gi, "\\$1") + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "$1"); } return result; }, 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 = $("
") .hide() .addclass(options.resultsclass) .css("position", "absolute") .appendto(document.body); list = $("