From 459719fc026be4e5019bf53f18ca874b8117c8b3 Mon Sep 17 00:00:00 2001 From: Tomas Kirda Date: Tue, 18 Dec 2012 16:07:02 -0600 Subject: [PATCH] Make plugin chainable, change suggestion event handling to improve performance, change suggestions format, update readme, add Jasmine test suites, add license. --- .gitignore | 4 + content/styles.css | 4 +- demo.htm | 12 +- dist/jquery.autocomplete.js | 577 ++++++ dist/jquery.autocomplete.min.js | 2 + dist/jquery.autocomplete.min.js.map | 8 + license.txt | 21 + readme.md | 64 +- scripts/demo.js | 48 +- scripts/jquery.mockjax.js | 523 +++++ spec/autocompleteBehavior.js | 60 + spec/lib/jasmine-1.3.1/MIT.LICENSE | 20 + spec/lib/jasmine-1.3.1/jasmine-html.js | 681 +++++++ spec/lib/jasmine-1.3.1/jasmine.css | 82 + spec/lib/jasmine-1.3.1/jasmine.js | 2600 ++++++++++++++++++++++++ spec/runner.html | 32 + src/jquery.autocomplete.js | 518 ++--- 17 files changed, 5009 insertions(+), 247 deletions(-) create mode 100644 dist/jquery.autocomplete.js create mode 100644 dist/jquery.autocomplete.min.js create mode 100644 dist/jquery.autocomplete.min.js.map create mode 100644 license.txt create mode 100644 scripts/jquery.mockjax.js create mode 100644 spec/autocompleteBehavior.js create mode 100644 spec/lib/jasmine-1.3.1/MIT.LICENSE create mode 100644 spec/lib/jasmine-1.3.1/jasmine-html.js create mode 100644 spec/lib/jasmine-1.3.1/jasmine.css create mode 100644 spec/lib/jasmine-1.3.1/jasmine.js create mode 100644 spec/runner.html diff --git a/.gitignore b/.gitignore index d7a4d4d..e1e4501 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ /*.user +/*.sln +/*.suo +/.idea* +/*.xml \ No newline at end of file diff --git a/content/styles.css b/content/styles.css index 00afaf1..a2719e4 100644 --- a/content/styles.css +++ b/content/styles.css @@ -3,8 +3,8 @@ .autocomplete-w1 { position: absolute; top: 0px; left: 0px; } .autocomplete { border: 1px solid #999; background: #FFF; cursor: default; text-align: left; max-height: 350px; overflow: auto; /* IE6 specific: */ _height: 350px; _margin: 0; _overflow-x: hidden; } - .autocomplete .selected { background: #F0F0F0; } + .autocomplete-selected { background: #F0F0F0; } .autocomplete div { padding: 2px 5px; white-space: nowrap; overflow: hidden; } .autocomplete strong { font-weight: normal; color: #3399FF; } -#query { font-size: 28px; padding: 10px; border: 1px solid #CCC; display: block; margin: 40px; } \ No newline at end of file +input { font-size: 28px; padding: 10px; border: 1px solid #CCC; display: block; margin: 20px 0; } \ No newline at end of file diff --git a/demo.htm b/demo.htm index 554dccc..fa94708 100644 --- a/demo.htm +++ b/demo.htm @@ -7,14 +7,24 @@

Ajax Autocomplete Demo

+ +

Ajax Lookup

Type country name in english:

- + +
+
+ +

Local Lookup

+

Type country name in english:

+
+
+ diff --git a/dist/jquery.autocomplete.js b/dist/jquery.autocomplete.js new file mode 100644 index 0000000..5aab395 --- /dev/null +++ b/dist/jquery.autocomplete.js @@ -0,0 +1,577 @@ +/** +* Ajax Autocomplete for jQuery, version 1.2 +* (c) 2012 Tomas Kirda +* +* Ajax Autocomplete for jQuery is freely distributable under the terms of an MIT-style license. +* For details, see the web site: http://www.devbridge.com/projects/autocomplete/jquery/ +* +* Last Review: 12/18/2012 +*/ + +/*jslint browser: true, white: true, plusplus: true, vars: true */ +/*global window: true, document: true, clearInterval: true, setInterval: true, jQuery: true */ + +(function ($) { + 'use strict'; + + var utils = (function () { + return { + + extend: function (target, source) { + return $.extend(target, source); + }, + + addEvent: function (element, eventType, handler) { + if (element.addEventListener) { + element.addEventListener(eventType, handler, false); + } else if (element.attachEvent) { + element.attachEvent('on' + eventType, handler); + } else { + throw new Error('Browser doesn\'t support addEventListener or attachEvent'); + } + }, + + removeEvent: function (element, eventType, handler) { + if (element.removeEventListener) { + element.removeEventListener(eventType, handler, false); + } else if (element.detachEvent) { + element.detachEvent('on' + eventType, handler); + } + }, + + createNode: function (html) { + var div = document.createElement('div'); + div.innerHTML = html; + return div.firstChild; + } + + }; + }()); + + function Autocomplete(el, options) { + var that = this, + defaults = { + minChars: 1, + maxHeight: 300, + deferRequestBy: 0, + width: 0, + highlight: true, + params: {}, + formatResult: Autocomplete.formatResult, + delimiter: null, + zIndex: 9999, + type: 'GET', + noCache: false, + enforce: false + }; + + // Shared variables: + that.element = el; + that.el = $(el); + that.suggestions = []; + that.badQueries = []; + that.selectedIndex = -1; + that.currentValue = that.element.value; + that.intervalId = 0; + that.cachedResponse = []; + that.onChangeInterval = null; + that.onChange = null; + that.ignoreValueChange = false; + that.isLocal = false; + that.suggestionsContainer = null; + that.options = defaults; + that.classes = { + selected: 'autocomplete-selected', + suggestion: 'autocomplete-suggestion' + }; + + // Initialize and set options: + that.initialize(); + that.setOptions(options); + } + + Autocomplete.utils = utils; + + $.Autocomplete = Autocomplete; + + Autocomplete.formatResult = function (suggestion, currentValue) { + var reEscape = new RegExp('(\\' + ['/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\'].join('|\\') + ')', 'g'), + pattern = '(' + currentValue.replace(reEscape, '\\$1') + ')'; + + return suggestion.value.replace(new RegExp(pattern, 'gi'), '$1<\/strong>'); + }; + + Autocomplete.prototype = { + + killerFn: null, + + initialize: function () { + var that = this, + suggestionSelector = '.' + that.classes.suggestion; + + // Remove autocomplete attribute to prevent native suggestions: + this.element.setAttribute('autocomplete', 'off'); + + this.killerFn = function (e) { + if ($(e.target).closest('.autocomplete').length === 0) { + that.killSuggestions(); + that.disableKillerFn(); + } + }; + + // Determine suggestions width: + if (!this.options.width || this.options.width === 'auto') { + this.options.width = this.el.outerWidth(); + } + + this.suggestionsContainer = Autocomplete.utils.createNode(''); + + var container = $(this.suggestionsContainer); + + container.appendTo('body').width(this.options.width); + + // Listen for mouse over event on suggestions list: + container.on('mouseover', suggestionSelector, function () { + that.activate($(this).data('index')); + }); + + // Listen for click event on suggestions list: + container.on('click', suggestionSelector, function () { + that.select($(this).data('index')); + }); + + this.fixPosition(); + + // Opera does not like keydown: + if (window.opera) { + this.el.on('keypress', function (e) { that.onKeyPress(e); }); + } else { + this.el.on('keydown', function (e) { that.onKeyPress(e); }); + } + + this.el.on('keyup', function (e) { that.onKeyUp(e); }); + this.el.on('blur', function () { that.onBlur(); }); + this.el.on('focus', function () { that.fixPosition(); }); + }, + + onBlur: function () { + this.enableKillerFn(); + }, + + setOptions: function (suppliedOptions) { + var options = this.options; + + utils.extend(options, suppliedOptions); + + this.isLocal = $.isArray(options.lookup); + + // Transform lookup array if it's string array: + if (this.isLocal && typeof options.lookup[0] === 'string') { + options.lookup = $.map(options.lookup, function (value) { + return { value: value, data: null }; + }); + } + + // Adjust height, width and z-index: + $(this.suggestionsContainer).css({ + 'max-height': options.maxHeight + 'px', + 'width': options.width, + 'z-index': options.zIndex + }); + }, + + clearCache: function () { + this.cachedResponse = []; + this.badQueries = []; + }, + + disable: function () { + this.disabled = true; + }, + + enable: function () { + this.disabled = false; + }, + + fixPosition: function () { + var offset = this.el.offset(); + $(this.suggestionsContainer).css({ + top: (offset.top + this.el.outerHeight()) + 'px', + left: offset.left + 'px' + }); + }, + + enableKillerFn: function () { + var that = this; + $(document).on('click', that.killerFn); + }, + + disableKillerFn: function () { + var that = this; + $(document).off('click', that.killerFn); + }, + + killSuggestions: function () { + var that = this; + that.stopKillSuggestions(); + that.intervalId = window.setInterval(function () { + that.hide(); + that.stopKillSuggestions(); + }, 300); + }, + + stopKillSuggestions: function () { + window.clearInterval(this.intervalId); + }, + + onKeyPress: function (e) { + // If suggestions are hidden and user presses arrow down, display suggestions: + if (!this.disabled && !this.visible && e.keyCode === 40 && this.currentValue) { + this.suggest(); + return; + } + + if (this.disabled || !this.visible) { + return; + } + + switch (e.keyCode) { + case 27: //KEY_ESC: + this.el.val(this.currentValue); + this.hide(); + break; + case 9: //KEY_TAB: + case 13: //KEY_RETURN: + if (this.selectedIndex === -1) { + this.hide(); + return; + } + this.select(this.selectedIndex); + if (e.keyCode === 9) { + return; + } + break; + case 38: //KEY_UP: + this.moveUp(); + break; + case 40: //KEY_DOWN: + this.moveDown(); + break; + default: + return; + } + + // Cancel event if function did not return: + e.stopImmediatePropagation(); + e.preventDefault(); + }, + + onKeyUp: function (e) { + if (this.disabled) { + return; + } + + switch (e.keyCode) { + case 38: //KEY_UP: + case 40: //KEY_DOWN: + return; + } + + clearInterval(this.onChangeInterval); + + if (this.currentValue !== this.el.val()) { + if (this.options.deferRequestBy > 0) { + // Defer lookup in case when value changes very quickly: + var me = this; + this.onChangeInterval = setInterval(function () { + me.onValueChange(); + }, this.options.deferRequestBy); + } else { + this.onValueChange(); + } + } + }, + + onValueChange: function () { + clearInterval(this.onChangeInterval); + this.currentValue = this.element.value; + var q = this.getQuery(this.currentValue); + this.selectedIndex = -1; + + if (this.ignoreValueChange) { + this.ignoreValueChange = false; + return; + } + + if (q === '' || q.length < this.options.minChars) { + this.hide(); + } else { + this.getSuggestions(q); + } + }, + + getQuery: function (value) { + var delimiter = this.options.delimiter, + parts; + + if (!delimiter) { + return $.trim(value); + } + parts = value.split(delimiter); + return $.trim(parts[parts.length - 1]); + }, + + getSuggestionsLocal: function (q) { + q = q.toLowerCase(); + + return { + suggestions: $.grep(this.options.lookup, function (suggestion) { + return suggestion.value.toLowerCase().indexOf(q) !== -1; + }) + }; + }, + + getSuggestions: function (q) { + var response, + that = this, + options = that.options; + + response = that.isLocal ? that.getSuggestionsLocal(q) : that.cachedResponse[q]; + + if (response && $.isArray(response.suggestions)) { + that.suggestions = response.suggestions; + that.suggest(); + } else if (!that.isBadQuery(q)) { + that.options.params.query = q; + $.ajax({ + url: options.serviceUrl, + data: options.params, + type: options.type, + dataType: 'text' + }).done(function (txt) { + that.processResponse(txt); + }); + } + }, + + isBadQuery: function (q) { + var badQueries = this.badQueries, + i = badQueries.length; + + while (i--) { + if (q.indexOf(badQueries[i]) === 0) { + return true; + } + } + + return false; + }, + + hide: function () { + this.visible = false; + this.selectedIndex = -1; + $(this.suggestionsContainer).hide(); + }, + + suggest: function () { + if (this.suggestions.length === 0) { + this.hide(); + return; + } + + var len = this.suggestions.length, + formatResults = this.options.formatResult, + value = this.getQuery(this.currentValue), + suggestion, + className = this.classes.suggestion, + classSelected = this.classes.selected, + container = $(this.suggestionsContainer), + html = '', + i; + + // Build suggestions inner HTML: + for (i = 0; i < len; i++) { + suggestion = this.suggestions[i]; + html += '
' + formatResults(suggestion, value) + '
'; + } + + container.html(html).show(); + this.visible = true; + + // Select first value by default: + this.selectedIndex = 0; + container.children().first().addClass(classSelected); + }, + + processResponse: function (text) { + var response = $.parseJSON(text); + + // If suggestions is string array, convert them to supported format: + if (typeof response.suggestions[0] === 'string') { + response.suggestions = $.map(response.suggestions, function (value) { + return { value: value, data: null }; + }); + } + + // Cache results if cache is not disabled: + if (!this.options.noCache) { + this.cachedResponse[response.query] = response; + if (response.suggestions.length === 0) { + this.badQueries.push(response.query); + } + } + + // Display suggestions only if returned query matches current value: + if (response.query === this.getQuery(this.currentValue)) { + this.suggestions = response.suggestions; + this.suggest(); + } + }, + + activate: function (index) { + var activeItem, + selected = this.classes.selected, + container = $(this.suggestionsContainer), + children = container.children(); + + container.children('.' + selected).removeClass(selected); + + this.selectedIndex = index; + + if (this.selectedIndex !== -1 && children.length > this.selectedIndex) { + activeItem = children.get(this.selectedIndex); + $(activeItem).addClass(selected); + return activeItem; + } + + return null; + }, + + select: function (i) { + var selectedValue = this.suggestions[i]; + + if (selectedValue) { + this.el.val(selectedValue); + this.ignoreValueChange = true; + this.hide(); + this.onSelect(i); + } + }, + + change: function (i) { + var onChange, + me = this, + selectedValue = this.suggestions[i], + suggestion; + + if (selectedValue) { + suggestion = me.suggestions[i]; + me.el.val(me.getValue(suggestion.value)); + + onChange = me.options.onChange; + if ($.isFunction(onChange)) { + onChange(suggestion, me.el); + } + } + }, + + moveUp: function () { + if (this.selectedIndex === -1) { + return; + } + + if (this.selectedIndex === 0) { + $(this.suggestionsContainer).children().first().removeClass(this.classes.selected); + this.selectedIndex = -1; + this.el.val(this.currentValue); + return; + } + + this.adjustScroll(this.selectedIndex - 1); + }, + + moveDown: function () { + if (this.selectedIndex === (this.suggestions.length - 1)) { + return; + } + + this.adjustScroll(this.selectedIndex + 1); + }, + + adjustScroll: function (index) { + var activeItem = this.activate(index), + offsetTop, + upperBound, + lowerBound, + heightDelta = 25; + + if (!activeItem) { + return; + } + + offsetTop = activeItem.offsetTop; + upperBound = $(this.suggestionsContainer).scrollTop(); + lowerBound = upperBound + this.options.maxHeight - heightDelta; + + if (offsetTop < upperBound) { + $(this.suggestionsContainer).scrollTop(offsetTop); + } else if (offsetTop > lowerBound) { + $(this.suggestionsContainer).scrollTop(offsetTop - this.options.maxHeight + heightDelta); + } + + this.el.val(this.getValue(this.suggestions[index].value)); + }, + + onSelect: function (index) { + var that = this, + onSelectCallback = that.options.onSelect, + suggestion = that.suggestions[index]; + + that.el.val(that.getValue(suggestion.value)); + + if ($.isFunction(onSelectCallback)) { + onSelectCallback.call(that.element, suggestion); + } + }, + + getValue: function (value) { + var that = this, + delimiter = that.options.delimiter, + currentValue, + parts; + + if (!delimiter) { + return value; + } + + currentValue = that.currentValue; + parts = currentValue.split(delimiter); + + if (parts.length === 1) { + return value; + } + + return currentValue.substr(0, currentValue.length - parts[parts.length - 1].length) + value; + } + }; + + // Create chainable jQuery plugin: + $.fn.autocomplete = function (options, args) { + return this.each(function () { + var dataKey = 'autocomplete', + inputElement = $(this), + instance; + + if (typeof options === 'string') { + instance = inputElement.data(dataKey); + if (typeof instance[options] === 'function') { + instance[options](args); + } + } else { + instance = new Autocomplete(this, options); + inputElement.data(dataKey, instance); + } + }); + }; + +}(jQuery)); diff --git a/dist/jquery.autocomplete.min.js b/dist/jquery.autocomplete.min.js new file mode 100644 index 0000000..9769e60 --- /dev/null +++ b/dist/jquery.autocomplete.min.js @@ -0,0 +1,2 @@ +(function(n){"use strict";function t(i,r){var u=this,f={minChars:1,maxHeight:300,deferRequestBy:0,width:0,highlight:!0,params:{},formatResult:t.formatResult,delimiter:null,zIndex:9999,type:"GET",noCache:!1,enforce:!1};u.element=i,u.el=n(i),u.suggestions=[],u.badQueries=[],u.selectedIndex=-1,u.currentValue=u.element.value,u.intervalId=0,u.cachedResponse=[],u.onChangeInterval=null,u.onChange=null,u.ignoreValueChange=!1,u.isLocal=!1,u.suggestionsContainer=null,u.options=f,u.classes={selected:"autocomplete-selected",suggestion:"autocomplete-suggestion"},u.initialize(),u.setOptions(r)}var i=function(){return{extend:function(t,i){return n.extend(t,i)},addEvent:function(n,t,i){if(n.addEventListener)n.addEventListener(t,i,!1);else if(n.attachEvent)n.attachEvent("on"+t,i);else throw new Error("Browser doesn't support addEventListener or attachEvent");},removeEvent:function(n,t,i){n.removeEventListener?n.removeEventListener(t,i,!1):n.detachEvent&&n.detachEvent("on"+t,i)},createNode:function(n){var t=document.createElement("div");return t.innerHTML=n,t.firstChild}}}();t.utils=i,n.Autocomplete=t,t.formatResult=function(n,t){var i=new RegExp("(\\/|\\.|\\*|\\+|\\?|\\||\\(|\\)|\\[|\\]|\\{|\\}|\\\\)","g"),r="("+t.replace(i,"\\$1")+")";return n.value.replace(new RegExp(r,"gi"),"$1<\/strong>")},t.prototype={killerFn:null,initialize:function(){var i=this,u="."+i.classes.suggestion,r;this.element.setAttribute("autocomplete","off"),this.killerFn=function(t){n(t.target).closest(".autocomplete").length===0&&(i.killSuggestions(),i.disableKillerFn())},this.options.width&&this.options.width!=="auto"||(this.options.width=this.el.outerWidth()),this.suggestionsContainer=t.utils.createNode('