/* | |
* 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); | |
data = null; // Avoid buggy cache and go to Solr every time | |
// 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: false, | |
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); |