Commit | Line | Data |
---|---|---|
dd916657 VZ |
1 | /* |
2 | * jQuery autocomplete plugin | |
3 | * Version 2.0.0 (2008-03-22) | |
4 | * @requires jQuery v1.1.1+ | |
5 | * | |
6 | * Dual licensed under the MIT and GPL licenses: | |
7 | * http://www.opensource.org/licenses/mit-license.php | |
8 | * http://www.gnu.org/licenses/gpl.html | |
9 | * | |
10 | * Dylan Verheul | |
11 | * http://www.dyve.net/jquery | |
12 | * | |
13 | */ | |
14 | (function($) { | |
15 | /** | |
16 | * The autocompleter object | |
17 | */ | |
18 | $.autocomplete = function(input, options) { | |
19 | ||
20 | // Create a link to self | |
21 | var me = this; | |
22 | // Create jQuery object for input element | |
23 | var $input = $(input).attr("autocomplete", "off"); | |
24 | // Apply inputClass if necessary | |
25 | if (options.inputClass) { | |
26 | $input.addClass(options.inputClass); | |
27 | } | |
28 | ||
29 | // Create results | |
30 | var results = document.createElement("div"); | |
31 | ||
32 | // Create jQuery object for results | |
33 | // var $results = $(results); | |
34 | var $results = $(results).hide().addClass(options.resultsClass).css("position", "absolute"); | |
35 | if( options.width > 0 ) { | |
36 | $results.css("width", options.width); | |
37 | } | |
38 | ||
39 | // Add to body element | |
40 | $("body").append(results); | |
41 | ||
42 | input.autocompleter = me; | |
43 | ||
44 | var timeout = null; | |
45 | var prev = ""; | |
46 | var active = -1; | |
47 | var cache = {}; | |
48 | var keyb = false; | |
49 | var hasFocus = false; | |
50 | var lastKeyPressCode = null; | |
51 | var mouseDownOnSelect = false; | |
52 | var hidingResults = false; | |
53 | ||
54 | // flush cache | |
55 | function flushCache(){ | |
56 | cache = {}; | |
57 | cache.data = {}; | |
58 | cache.length = 0; | |
59 | }; | |
60 | ||
61 | // flush cache | |
62 | flushCache(); | |
63 | ||
64 | // if there is a data array supplied | |
65 | if( options.data != null ){ | |
66 | var sFirstChar = "", stMatchSets = {}, row = []; | |
67 | ||
68 | // no url was specified, we need to adjust the cache length to make sure it fits the local data store | |
69 | if (typeof options.url != "string") { | |
70 | options.cacheLength = 1; | |
71 | } | |
72 | ||
73 | // loop through the array and create a lookup structure | |
74 | for( var i=0; i < options.data.length; i++ ){ | |
75 | // if row is a string, make an array otherwise just reference the array | |
76 | row = ((typeof options.data[i] == "string") ? [options.data[i]] : options.data[i]); | |
77 | ||
78 | // if the length is zero, don't add to list | |
79 | if( row[0].length > 0 ){ | |
80 | // get the first character | |
81 | sFirstChar = row[0].substring(0, 1).toLowerCase(); | |
82 | // if no lookup array for this character exists, look it up now | |
83 | if( !stMatchSets[sFirstChar] ) stMatchSets[sFirstChar] = []; | |
84 | // if the match is a string | |
85 | stMatchSets[sFirstChar].push(row); | |
86 | } | |
87 | } | |
88 | ||
89 | // add the data items to the cache | |
90 | for (var k in stMatchSets) { | |
91 | // increase the cache size | |
92 | options.cacheLength++; | |
93 | // add to the cache | |
94 | addToCache(k, stMatchSets[k]); | |
95 | } | |
96 | } | |
97 | ||
98 | $input | |
99 | .keydown(function(e) { | |
100 | // track last key pressed | |
101 | lastKeyPressCode = e.keyCode; | |
102 | switch(e.keyCode) { | |
103 | case 38: // up | |
104 | e.preventDefault(); | |
105 | moveSelect(-1); | |
106 | break; | |
107 | case 40: // down | |
108 | e.preventDefault(); | |
109 | moveSelect(1); | |
110 | break; | |
111 | case 9: // tab | |
112 | case 13: // return | |
113 | if( selectCurrent() ){ | |
114 | // make sure to blur off the current field | |
115 | $input.blur(); | |
116 | e.preventDefault(); | |
117 | // give focus with a small timeout (weird behaviour in FF) | |
118 | setTimeout(function() { $input.focus() }, 10); | |
119 | } | |
120 | break; | |
121 | default: | |
122 | active = -1; | |
123 | if (timeout) clearTimeout(timeout); | |
124 | timeout = setTimeout(onChange, options.delay); | |
125 | break; | |
126 | } | |
127 | }) | |
128 | .focus(function(){ | |
129 | // track whether the field has focus, we shouldn't process any results if the field no longer has focus | |
130 | hasFocus = true; | |
131 | }) | |
132 | .blur(function() { | |
133 | // track whether the field has focus | |
134 | hasFocus = false; | |
135 | if (!mouseDownOnSelect) { | |
136 | hideResults(); | |
137 | } | |
138 | }); | |
139 | ||
140 | hideResultsNow(); | |
141 | ||
142 | function onChange() { | |
143 | // ignore if the following keys are pressed: [del] [shift] [capslock] | |
144 | if( lastKeyPressCode == 46 || (lastKeyPressCode > 8 && lastKeyPressCode < 32) ) return $results.hide(); | |
145 | var v = $input.val(); | |
146 | if (v == prev) return; | |
147 | prev = v; | |
148 | if (v.length >= options.minChars) { | |
149 | $input.addClass(options.loadingClass); | |
150 | requestData(v); | |
151 | } else { | |
152 | $input.removeClass(options.loadingClass); | |
153 | $results.hide(); | |
154 | } | |
155 | }; | |
156 | ||
157 | function moveSelect(step) { | |
158 | ||
159 | var lis = $("li", results); | |
160 | if (!lis) return; | |
161 | ||
162 | active += step; | |
163 | ||
164 | if (active < 0) { | |
165 | active = 0; | |
166 | } else if (active >= lis.size()) { | |
167 | active = lis.size() - 1; | |
168 | } | |
169 | ||
170 | lis.removeClass("ac_over"); | |
171 | ||
172 | $(lis[active]).addClass("ac_over"); | |
173 | ||
174 | // Weird behaviour in IE | |
175 | // if (lis[active] && lis[active].scrollIntoView) { | |
176 | // lis[active].scrollIntoView(false); | |
177 | // } | |
178 | ||
179 | }; | |
180 | ||
181 | function selectCurrent() { | |
182 | var li = $("li.ac_over", results)[0]; | |
183 | if (!li) { | |
184 | var $li = $("li", results); | |
185 | if (options.selectOnly) { | |
186 | if ($li.length == 1) li = $li[0]; | |
187 | } else if (options.selectFirst) { | |
188 | li = $li[0]; | |
189 | } | |
190 | } | |
191 | if (li) { | |
192 | selectItem(li); | |
193 | return true; | |
194 | } else { | |
195 | return false; | |
196 | } | |
197 | }; | |
198 | ||
199 | function selectItem(li) { | |
200 | if (!li) { | |
201 | li = document.createElement("li"); | |
202 | li.extra = []; | |
203 | li.selectValue = ""; | |
204 | } | |
205 | var v = $.trim(li.selectValue ? li.selectValue : li.innerHTML); | |
206 | input.lastSelected = v; | |
207 | prev = v; | |
208 | $results.html(""); | |
209 | $input.val(v); | |
210 | hideResultsNow(); | |
211 | if (options.onItemSelect) { | |
212 | setTimeout(function() { options.onItemSelect(li) }, 1); | |
213 | } | |
214 | }; | |
215 | ||
216 | // selects a portion of the input string | |
217 | function createSelection(start, end){ | |
218 | // get a reference to the input element | |
219 | var field = $input.get(0); | |
220 | if( field.createTextRange ){ | |
221 | var selRange = field.createTextRange(); | |
222 | selRange.collapse(true); | |
223 | selRange.moveStart("character", start); | |
224 | selRange.moveEnd("character", end); | |
225 | selRange.select(); | |
226 | } else if( field.setSelectionRange ){ | |
227 | field.setSelectionRange(start, end); | |
228 | } else { | |
229 | if( field.selectionStart ){ | |
230 | field.selectionStart = start; | |
231 | field.selectionEnd = end; | |
232 | } | |
233 | } | |
234 | field.focus(); | |
235 | }; | |
236 | ||
237 | // fills in the input box w/the first match (assumed to be the best match) | |
238 | function autoFill(sValue){ | |
239 | // if the last user key pressed was backspace, don't autofill | |
240 | if( lastKeyPressCode != 8 ){ | |
241 | // fill in the value (keep the case the user has typed) | |
242 | $input.val($input.val() + sValue.substring(prev.length)); | |
243 | // select the portion of the value not typed by the user (so the next character will erase) | |
244 | createSelection(prev.length, sValue.length); | |
245 | } | |
246 | }; | |
247 | ||
248 | function showResults() { | |
249 | // get the position of the input field right now (in case the DOM is shifted) | |
250 | var pos = findPos(input); | |
251 | // either use the specified width, or autocalculate based on form element | |
252 | var iWidth = (options.width > 0) ? options.width : $input.width(); | |
253 | // reposition | |
254 | $results.css({ | |
255 | width: parseInt(iWidth) + "px", | |
256 | top: (pos.y + input.offsetHeight) + "px", | |
257 | left: pos.x + "px" | |
258 | }).show(); | |
259 | }; | |
260 | ||
261 | function hideResults() { | |
262 | if (timeout) clearTimeout(timeout); | |
263 | timeout = setTimeout(hideResultsNow, 200); | |
264 | }; | |
265 | ||
266 | function hideResultsNow() { | |
267 | if (hidingResults) { | |
268 | return; | |
269 | } | |
270 | hidingResults = true; | |
271 | ||
272 | if (timeout) { | |
273 | clearTimeout(timeout); | |
274 | } | |
275 | ||
276 | var v = $input.removeClass(options.loadingClass).val(); | |
277 | ||
278 | if ($results.is(":visible")) { | |
279 | $results.hide(); | |
280 | } | |
281 | ||
282 | if (options.mustMatch) { | |
283 | if (!input.lastSelected || input.lastSelected != v) { | |
284 | selectItem(null); | |
285 | } | |
286 | } | |
287 | ||
288 | hidingResults = false; | |
289 | }; | |
290 | ||
291 | function receiveData(q, data) { | |
292 | if (data) { | |
293 | $input.removeClass(options.loadingClass); | |
294 | results.innerHTML = ""; | |
295 | ||
296 | // if the field no longer has focus or if there are no matches, do not display the drop down | |
297 | if( !hasFocus || data.length == 0 ) return hideResultsNow(); | |
298 | ||
299 | if ($.browser.msie) { | |
300 | // we put a styled iframe behind the calendar so HTML SELECT elements don't show through | |
301 | $results.append(document.createElement('iframe')); | |
302 | } | |
303 | results.appendChild(dataToDom(data)); | |
304 | // autofill in the complete box w/the first match as long as the user hasn't entered in more data | |
305 | if( options.autoFill && ($input.val().toLowerCase() == q.toLowerCase()) ) autoFill(data[0][0]); | |
306 | showResults(); | |
307 | } else { | |
308 | hideResultsNow(); | |
309 | } | |
310 | }; | |
311 | ||
312 | function parseData(data) { | |
313 | if (!data) return null; | |
314 | var parsed = []; | |
315 | var rows = data.split(options.lineSeparator); | |
316 | for (var i=0; i < rows.length; i++) { | |
317 | var row = $.trim(rows[i]); | |
318 | if (row) { | |
319 | parsed[parsed.length] = row.split(options.cellSeparator); | |
320 | } | |
321 | } | |
322 | return parsed; | |
323 | }; | |
324 | ||
325 | function dataToDom(data) { | |
326 | var ul = document.createElement("ul"); | |
327 | var num = data.length; | |
328 | ||
329 | // limited results to a max number | |
330 | if( (options.maxItemsToShow > 0) && (options.maxItemsToShow < num) ) num = options.maxItemsToShow; | |
331 | ||
332 | for (var i=0; i < num; i++) { | |
333 | var row = data[i]; | |
334 | if (!row) continue; | |
335 | var li = document.createElement("li"); | |
336 | if (options.formatItem) { | |
337 | li.innerHTML = options.formatItem(row, i, num); | |
338 | li.selectValue = row[0]; | |
339 | } else { | |
340 | li.innerHTML = row[0]; | |
341 | li.selectValue = row[0]; | |
342 | } | |
343 | var extra = null; | |
344 | if (row.length > 1) { | |
345 | extra = []; | |
346 | for (var j=1; j < row.length; j++) { | |
347 | extra[extra.length] = row[j]; | |
348 | } | |
349 | } | |
350 | li.extra = extra; | |
351 | ul.appendChild(li); | |
352 | ||
353 | $(li).hover( | |
354 | function() { | |
355 | var $this = $(this); | |
356 | active = $("li", ul).removeClass("ac_over").index($this[0]); | |
357 | $this.addClass("ac_over"); | |
358 | }, | |
359 | function() { | |
360 | $(this).removeClass("ac_over"); | |
361 | } | |
362 | ).click(function(e) { | |
363 | e.preventDefault(); | |
364 | e.stopPropagation(); | |
365 | selectItem(this) | |
366 | }); | |
367 | ||
368 | } | |
369 | $(ul).mousedown(function() { | |
370 | mouseDownOnSelect = true; | |
371 | }).mouseup(function() { | |
372 | mouseDownOnSelect = false; | |
373 | }); | |
374 | return ul; | |
375 | }; | |
376 | ||
377 | function requestData(q) { | |
378 | if (!options.matchCase) q = q.toLowerCase(); | |
379 | var data = options.cacheLength ? loadFromCache(q) : null; | |
380 | // recieve the cached data | |
381 | if (data) { | |
382 | receiveData(q, data); | |
383 | // if an AJAX url has been supplied, try loading the data now | |
384 | } else if( (typeof options.url == "string") && (options.url.length > 0) ){ | |
385 | $.get(makeUrl(q), function(data) { | |
386 | data = parseData(data); | |
387 | addToCache(q, data); | |
388 | receiveData(q, data); | |
389 | }); | |
390 | // if there's been no data found, remove the loading class | |
391 | } else { | |
392 | $input.removeClass(options.loadingClass); | |
393 | } | |
394 | }; | |
395 | ||
396 | function makeUrl(q) { | |
397 | var sep = options.url.indexOf('?') == -1 ? '?' : '&'; | |
398 | var url = options.url + sep + "q=" + encodeURI(q); | |
399 | for (var i in options.extraParams) { | |
400 | url += "&" + i + "=" + encodeURI(options.extraParams[i]); | |
401 | } | |
402 | return url; | |
403 | }; | |
404 | ||
405 | function loadFromCache(q) { | |
406 | if (!q) return null; | |
407 | if (cache.data[q]) return cache.data[q]; | |
408 | if (options.matchSubset) { | |
409 | for (var i = q.length - 1; i >= options.minChars; i--) { | |
410 | var qs = q.substr(0, i); | |
411 | var c = cache.data[qs]; | |
412 | if (c) { | |
413 | var csub = []; | |
414 | for (var j = 0; j < c.length; j++) { | |
415 | var x = c[j]; | |
416 | var x0 = x[0]; | |
417 | if (matchSubset(x0, q)) { | |
418 | csub[csub.length] = x; | |
419 | } | |
420 | } | |
421 | return csub; | |
422 | } | |
423 | } | |
424 | } | |
425 | return null; | |
426 | }; | |
427 | ||
428 | function matchSubset(s, sub) { | |
429 | if (!options.matchCase) s = s.toLowerCase(); | |
430 | var i = s.indexOf(sub); | |
431 | if (i == -1) return false; | |
432 | return i == 0 || options.matchContains; | |
433 | }; | |
434 | ||
435 | this.flushCache = function() { | |
436 | flushCache(); | |
437 | }; | |
438 | ||
439 | this.setExtraParams = function(p) { | |
440 | options.extraParams = p; | |
441 | }; | |
442 | ||
443 | this.findValue = function(){ | |
444 | var q = $input.val(); | |
445 | ||
446 | if (!options.matchCase) q = q.toLowerCase(); | |
447 | var data = options.cacheLength ? loadFromCache(q) : null; | |
448 | if (data) { | |
449 | findValueCallback(q, data); | |
450 | } else if( (typeof options.url == "string") && (options.url.length > 0) ){ | |
451 | $.get(makeUrl(q), function(data) { | |
452 | data = parseData(data) | |
453 | addToCache(q, data); | |
454 | findValueCallback(q, data); | |
455 | }); | |
456 | } else { | |
457 | // no matches | |
458 | findValueCallback(q, null); | |
459 | } | |
460 | } | |
461 | ||
462 | function findValueCallback(q, data){ | |
463 | if (data) $input.removeClass(options.loadingClass); | |
464 | ||
465 | var num = (data) ? data.length : 0; | |
466 | var li = null; | |
467 | ||
468 | for (var i=0; i < num; i++) { | |
469 | var row = data[i]; | |
470 | ||
471 | if( row[0].toLowerCase() == q.toLowerCase() ){ | |
472 | li = document.createElement("li"); | |
473 | if (options.formatItem) { | |
474 | li.innerHTML = options.formatItem(row, i, num); | |
475 | li.selectValue = row[0]; | |
476 | } else { | |
477 | li.innerHTML = row[0]; | |
478 | li.selectValue = row[0]; | |
479 | } | |
480 | var extra = null; | |
481 | if( row.length > 1 ){ | |
482 | extra = []; | |
483 | for (var j=1; j < row.length; j++) { | |
484 | extra[extra.length] = row[j]; | |
485 | } | |
486 | } | |
487 | li.extra = extra; | |
488 | } | |
489 | } | |
490 | ||
491 | if( options.onFindValue ) setTimeout(function() { options.onFindValue(li) }, 1); | |
492 | } | |
493 | ||
494 | function addToCache(q, data) { | |
495 | if (!data || !q || !options.cacheLength) return; | |
496 | if (!cache.length || cache.length > options.cacheLength) { | |
497 | flushCache(); | |
498 | cache.length++; | |
499 | } else if (!cache[q]) { | |
500 | cache.length++; | |
501 | } | |
502 | cache.data[q] = data; | |
503 | }; | |
504 | ||
505 | function findPos(obj) { | |
506 | var curleft = obj.offsetLeft || 0; | |
507 | var curtop = obj.offsetTop || 0; | |
508 | while (obj = obj.offsetParent) { | |
509 | curleft += obj.offsetLeft | |
510 | curtop += obj.offsetTop | |
511 | } | |
512 | return {x:curleft,y:curtop}; | |
513 | } | |
514 | } | |
515 | ||
516 | /** | |
517 | * The autocomplete plugin itself | |
518 | */ | |
519 | $.fn.autocomplete = function(url, options, data) { | |
520 | ||
521 | // Make sure options exists | |
522 | options = options || {}; | |
523 | // Set url as option | |
524 | options.url = url; | |
525 | // set some bulk local data | |
526 | options.data = ((typeof data == "object") && (data.constructor == Array)) ? data : null; | |
527 | ||
528 | // Set default values for required options (set global defaults in $.fn.autocomplete.defaults) | |
529 | options = $.extend({ | |
530 | inputClass: "ac_input", | |
531 | resultsClass: "ac_results", | |
532 | lineSeparator: "\n", | |
533 | cellSeparator: "|", | |
534 | minChars: 1, | |
535 | delay: 400, | |
536 | matchCase: 0, | |
537 | matchSubset: 1, | |
538 | matchContains: 0, | |
539 | cacheLength: 1, | |
540 | mustMatch: 0, | |
541 | extraParams: {}, | |
542 | loadingClass: "ac_loading", | |
543 | selectFirst: false, | |
544 | selectOnly: false, | |
545 | maxItemsToShow: -1, | |
546 | autoFill: false, | |
547 | width: 0 | |
548 | }, $.fn.autocomplete.defaults, options); | |
549 | ||
550 | options.width = parseInt(options.width, 10); | |
551 | ||
552 | return this.each(function() { | |
553 | var input = this; | |
554 | new $.autocomplete(input, options); | |
555 | }); | |
556 | ||
557 | } | |
558 | ||
559 | })(jQuery); |