vendor-all-the-javascripts/vendor/assets/javascripts/jquery.better-autocomplete.js
2011-11-25 10:06:17 -05:00

864 lines
26 KiB
JavaScript

/**
* @fileOverview Better Autocomplete is a flexible jQuery plugin which offers
* rich text autocompletion, both from local and remote sources.
*
* @author Didrik Nordström, http://betamos.se/
*
* @version v1.0-dev
*
* @requires
* <ul><li>
* jQuery 1.4+
* </li><li>
* IE7+ or any decent webkit/gecko-based web browser
* </li></ul>
*
* @preserve Better Autocomplete v1.0-dev
* https://github.com/betamos/Better-Autocomplete
*
* Copyright 2011, Didrik Nordström, http://betamos.se/
* Dual licensed under the MIT or GPL Version 2 licenses.
*
* Requires jQuery 1.4+
* http://jquery.com/
*/
/**
* Create or alter an autocomplete object instance that belongs to
* the elements in the selection. Make sure there are only text field elements
* in the selection.
*
* @constructor
*
* @name jQuery.betterAutocomplete
*
* @param {String} method
* Should be one of the following:
* <ul><li>
* init: Initiate Better Autocomplete instances on the text input elements
* in the current jQuery selection. They are enabled by default. The other
* parameters are then required.
* </li><li>
* enable: In this jQuery selection, reenable the Better Autocomplete
* instances.
* </li><li>
* disable: In this jQuery selection, disable the Better Autocomplete
* instances.
* </li><li>
* destroy: In this jQuery selection, destroy the Better Autocomplete
* instances. It will not be possible to reenable them after this.
* </li></ul>
*
* @param {String|Object} [resource]
* If String, it will become the path for a remote resource. If not, it will
* be treated like a local resource. The path should provide JSON objects
* upon HTTP requests.
*
* @param {Object} [options]
* An object with configurable options:
* <ul><li>
* charLimit: (default=3 for remote or 1 for local resource) The minimum
* number of chars to do an AJAX call. A typical use case for this limit is
* to reduce server load.
* </li><li>
* delay: (default=350) The time in ms between last keypress and AJAX call.
* Typically used to prevent looking up irrelevant strings while the user
* is still typing. Only relevant for remote resources.
* </li><li>
* caseSensitive: (default=false) If the search should be case sensitive.
* If false, query strings will be converted to lowercase.
* </li><li>
* cacheLimit: (default=256 for remote or 0 for local resource) The maximum
* number of result objects to store in the cache. This option reduces
* server load if the user deletes characters to check back on previous
* results. To disable caching of previous results, set this option to 0.
* </li><li>
* remoteTimeout: (default=10000) The timeout for remote (AJAX) calls.
* </li><li>
* crossOrigin: (default=false) Set to true if cross origin requests will
* be performed, i.e. that the remote URL has a different domain. This will
* force Internet Explorer to use "jsonp" instead of "json" as datatype.
* </li><li>
* selectKeys: (default=[9, 13]) The key codes for keys which will select
* the current highlighted element. The defaults are tab, enter.
* </li></ul>
*
* @param {Object} [callbacks]
* An object containing optional callback functions on certain events. See
* {@link callbacks} for details. These callbacks should be used when
* customization of the default behavior of Better Autocomplete is required.
*
* @returns {Object}
* The jQuery object with the same element selection, for chaining.
*/
(function($) {
$.fn.betterAutocomplete = function(method) {
/*
* Each method expects the "this" object to be a valid DOM text input node.
* The methods "enable", "disable" and "destroy" expects an instance of a
* BetterAutocomplete object as their first argument.
*/
var methods = {
init: function(resource, options, callbacks) {
var $input = $(this),
bac = new BetterAutocomplete($input, resource, options, callbacks);
$input.data('better-autocomplete', bac);
bac.enable();
},
enable: function(bac) {
bac.enable();
},
disable: function(bac) {
bac.disable();
},
destroy: function(bac) {
bac.destroy();
}
}, args = Array.prototype.slice.call(arguments, 1);
// Method calling logic
this.each(function() {
switch (method) {
case 'init':
methods[method].apply(this, args);
break;
case 'enable':
case 'disable':
case 'destroy':
var bac = $(this).data('better-autocomplete');
if (bac instanceof BetterAutocomplete) {
methods[method].call(this, bac);
}
break;
default:
$.error(['Method', method,
'does not exist in jQuery.betterAutocomplete.'].join(' '));
}
});
// Maintain chainability
return this;
};
/**
* The BetterAutocomplete constructor function. Returns a BetterAutocomplete
* instance object.
*
* @private @constructor
* @name BetterAutocomplete
*
* @param {Object} $input
* A single input element wrapped in jQuery.
*/
var BetterAutocomplete = function($input, resource, options, callbacks) {
var lastRenderedQuery = '',
cache = {}, // Key-valued caching of search results
cacheOrder = [], // Array of query strings, in the order they are added
cacheSize = 0, // Keep count of the cache's size
timer, // Used for options.delay
activeRemoteCalls = [], // A flat array of query strings that are pending
disableMouseHighlight = false, // Suppress the autotriggered mouseover event
inputEvents = {},
isLocal = ($.type(resource) != 'string'),
$results = $('<ul />').addClass('better-autocomplete'),
hiddenResults = true, // $results are hidden
preventBlurTimer = null; // IE bug workaround, see below in code.
options = $.extend({
charLimit: isLocal ? 1 : 3,
delay: 350, // milliseconds
caseSensitive: false,
cacheLimit: isLocal ? 0 : 256, // Number of result objects
remoteTimeout: 10000, // milliseconds
crossOrigin: false,
selectKeys: [9, 13] // [tab, enter]
}, options);
callbacks = $.extend({}, defaultCallbacks, callbacks);
callbacks.insertSuggestionList($results, $input);
inputEvents.focus = function() {
// If the blur timer is active, a redraw is redundant.
preventBlurTimer || redraw(true);
};
inputEvents.blur = function() {
// If the blur prevention timer is active, refocus the input, since the
// blur event can not be cancelled.
if (preventBlurTimer) {
$input.focus();
}
else {
// The input has already lost focus, so redraw the suggestion list.
redraw();
}
};
inputEvents.keydown = function(event) {
var index = getHighlighted();
// If an arrow key is pressed and a result is highlighted
if ($.inArray(event.keyCode, [38, 40]) >= 0 && index >= 0) {
var newIndex,
size = $('.result', $results).length;
switch (event.keyCode) {
case 38: // Up arrow
newIndex = Math.max(0, index - 1);
break;
case 40: // Down arrow
newIndex = Math.min(size - 1, index + 1);
break;
}
disableMouseHighlight = true;
setHighlighted(newIndex, true);
return false;
}
// A select key has been pressed
else if ($.inArray(event.keyCode, options.selectKeys) >= 0 &&
!event.shiftKey && !event.ctrlKey && !event.altKey &&
!event.metaKey) {
select();
return event.keyCode == 9; // Never cancel tab
}
};
inputEvents.keyup = inputEvents.click = reprocess;
$('.result', $results[0]).live({
// When the user hovers a result with the mouse, highlight it.
mouseover: function() {
if (disableMouseHighlight) {
return;
}
setHighlighted($('.result', $results).index($(this)));
},
mousemove: function() {
// Enable mouseover again.
disableMouseHighlight = false;
},
mousedown: function() {
select();
return false;
}
});
// Prevent blur when clicking on group titles, scrollbars etc.,
// This event is triggered after the others because of bubbling.
$results.mousedown(function() {
// Bug in IE where clicking on scrollbar would trigger a blur event for the
// input field, despite using preventDefault() on the mousedown event.
// This workaround locks the blur event on the input for a small time.
clearTimeout(preventBlurTimer);
preventBlurTimer = setTimeout(function() {
preventBlurTimer = null;
}, 50);
return false;
});
/*
* PUBLIC METHODS
*/
/**
* Enable this instance.
*/
this.enable = function() {
// Turn off the browser's autocompletion
$input
.attr('autocomplete', 'OFF')
.attr('aria-autocomplete', 'list');
$input.bind(inputEvents);
};
/**
* Disable this instance.
*/
this.disable = function() {
$input
.removeAttr('autocomplete')
.removeAttr('aria-autocomplete');
$results.hide();
$input.unbind(inputEvents);
};
/**
* Disable and remove this instance. This instance should not be reused.
*/
this.destroy = function() {
$results.remove();
$input.unbind(inputEvents);
$input.removeData('better-autocomplete');
};
/*
* PRIVATE METHODS
*/
/**
* Add an array of results to the cache. Internal methods always reads from
* the cache, so this method must be invoked even when caching is not used,
* e.g. when using local results. This method automatically clears as much of
* the cache as required to fit within the cache limit.
*
* @param {String} query
* The query to set the results to.
*
* @param {Array[Object]} results
* The array of results for this query.
*/
var cacheResults = function(query, results) {
cacheSize += results.length;
// Now reduce size until it fits
while (cacheSize > options.cacheLimit && cacheOrder.length) {
var key = cacheOrder.shift();
cacheSize -= cache[key].length;
delete cache[key];
}
cacheOrder.push(query);
cache[query] = results;
};
/**
* Set highlight to a specific result item
*
* @param {Number} index
* The result's index, starting at 0.
*
* @param {Boolean} [autoScroll]
* (default=false) If scrolling of the results list should be automated.
*/
var setHighlighted = function(index, autoScroll) {
// Scrolling upwards
var up = index == 0 || index < getHighlighted(),
$scrollTo = $('.result', $results)
.removeClass('highlight')
.eq(index).addClass('highlight');
if (!autoScroll) {
return;
}
// Scrolling up, then make sure to show the group title
if ($scrollTo.prev().is('.group') && up) {
$scrollTo = $scrollTo.prev();
}
// Is $scrollTo partly above the visible region?
if ($scrollTo.position().top < 0) {
$results.scrollTop($scrollTo.position().top + $results.scrollTop());
}
// Or is it partly below the visible region?
else if (($scrollTo.position().top + $scrollTo.outerHeight()) >
$results.height()) {
$results.scrollTop($scrollTo.position().top + $results.scrollTop() +
$scrollTo.outerHeight() - $results.height());
}
};
/**
* Retrieve the index of the currently highlighted result item
*
* @returns {Number}
* The result's index or -1 if no result is highlighted.
*/
var getHighlighted = function() {
return $('.result', $results).index($('.result.highlight', $results));
};
/**
* Select the current highlighted element, if any.
*/
var select = function() {
var $result = $('.result', $results).eq(getHighlighted());
if (!$result.length) {
return; // No selectable element
}
var result = $result.data('result');
callbacks.select(result, $input);
// Redraw again, if the callback changed focus or content
reprocess();
};
/**
* Fetch results asynchronously via AJAX.
* Errors are ignored.
*
* @param {String} query
* The query string.
*/
var fetchResults = function(query) {
// Synchronously fetch local data
if (isLocal) {
cacheResults(query, callbacks.queryLocalResults(query, resource,
options.caseSensitive));
redraw();
}
// Asynchronously fetch remote data
else {
activeRemoteCalls.push(query);
var url = callbacks.constructURL(resource, query);
callbacks.beginFetching($input);
callbacks.fetchRemoteData(url, function(data) {
var searchResults = callbacks.processRemoteData(data);
if (!$.isArray(searchResults)) {
searchResults = [];
}
cacheResults(query, searchResults);
// Remove the query from active remote calls, since it's finished
activeRemoteCalls = $.grep(activeRemoteCalls, function(value) {
return value != query;
});
if (!activeRemoteCalls.length) {
callbacks.finishFetching($input);
}
redraw();
}, options.remoteTimeout, options.crossOrigin);
}
};
/**
* Reprocess the contents of the input field, fetch data and redraw if
* necessary.
*/
function reprocess() {
var query = callbacks.canonicalQuery($input.val(), options.caseSensitive);
clearTimeout(timer);
// Indicate that timer is inactive
timer = null;
redraw();
if (query.length >= options.charLimit && !$.isArray(cache[query]) &&
$.inArray(query, activeRemoteCalls) == -1) {
// Fetching is required
$results.empty();
if (isLocal) {
fetchResults(query);
}
else {
timer = setTimeout(function() {
fetchResults(query);
timer = null;
}, options.delay);
}
}
};
/**
* Redraws the autocomplete list based on current query and focus.
*
* @param {Boolean} [focus]
* (default=false) Force to treat the input element like it's focused.
*/
var redraw = function(focus) {
var query = callbacks.canonicalQuery($input.val(), options.caseSensitive);
// The query does not exist in db
if (!$.isArray(cache[query])) {
lastRenderedQuery = null;
$results.empty();
}
// The query exists and is not already rendered
else if (lastRenderedQuery !== query) {
lastRenderedQuery = query;
renderResults(cache[query]);
setHighlighted(0);
}
// Finally show/hide based on focus and emptiness
if (($input.is(':focus') || focus) && !$results.is(':empty')) {
$results.filter(':hidden').show() // Show if hidden
.scrollTop($results.data('scroll-top')); // Reset the lost scrolling
if (hiddenResults) {
hiddenResults = false;
callbacks.afterShow($results);
}
}
else if ($results.is(':visible')) {
// Store the scrolling position for later
$results.data('scroll-top', $results.scrollTop())
.hide(); // Hiding it resets it's scrollTop
if (!hiddenResults) {
hiddenResults = true;
callbacks.afterHide($results);
}
}
};
/**
* Regenerate the DOM content within the results list for a given set of
* results. Heavy method, use only when necessary.
*
* @param {Array[Object]} results
* An array of result objects to render.
*/
var renderResults = function(results) {
$results.empty();
var groups = {}; // Key is the group name, value is the heading element.
$.each(results, function(index, result) {
if ($.type(result) != 'object') {
return; // Continue
}
var output = callbacks.themeResult(result);
if ($.type(output) != 'string') {
return; // Continue
}
// Add the group if it doesn't exist
var group = callbacks.getGroup(result);
if ($.type(group) == 'string' && !groups[group]) {
var $groupHeading = $('<li />').addClass('group')
.append($('<h3 />').html(group))
.appendTo($results);
groups[group] = $groupHeading;
}
var $result = $('<li />').addClass('result')
.append(output)
.data('result', result) // Store the result object on this DOM element
.addClass(result.addClass);
// First groupless item
if ($.type(group) != 'string' &&
!$results.children().first().is('.result')) {
$results.prepend($result);
return; // Continue
}
var $traverseFrom = ($.type(group) == 'string') ?
groups[group] : $results.children().first();
var $target = $traverseFrom.nextUntil('.group').last();
$result.insertAfter($target.length ? $target : $traverseFrom);
});
};
};
/*
* CALLBACK METHODS
*/
/**
* These callbacks are supposed to be overridden by you when you need
* customization of the default behavior. When you are overriding a callback
* function, it is a good idea to copy the source code from the default
* callback function, as a skeleton.
*
* @name callbacks
* @namespace
*/
var defaultCallbacks = {
/**
* @lends callbacks.prototype
*/
/**
* Gets fired when the user selects a result by clicking or using the
* keyboard to select an element.
*
* <br /><br /><em>Default behavior: Inserts the result's title into the
* input field.</em>
*
* @param {Object} result
* The result object that was selected.
*
* @param {Object} $input
* The input DOM element, wrapped in jQuery.
*/
select: function(result, $input) {
$input.val(result.title);
},
/**
* Given a result object, theme it to HTML.
*
* <br /><br /><em>Default behavior: Wraps result.title in an h4 tag, and
* result.description in a p tag. Note that no sanitization of malicious
* scripts is done here. Whatever is within the title/description is just
* printed out. May contain HTML.</em>
*
* @param {Object} result
* The result object that should be rendered.
*
* @returns {String}
* HTML output, will be wrapped in a list element.
*/
themeResult: function(result) {
var output = [];
if ($.type(result.title) == 'string') {
output.push('<h4>', result.title, '</h4>');
}
if ($.type(result.description) == 'string') {
output.push('<p>', result.description, '</p>');
}
return output.join('');
},
/**
* Retrieve local results from the local resource by providing a query
* string.
*
* <br /><br /><em>Default behavior: Automatically handles arrays, if the
* data inside each element is either a plain string or a result object.
* If it is a result object, it will match the query string against the
* title and description property. Search is not case sensitive.</em>
*
* @param {String} query
* The query string, unescaped. May contain any UTF-8 character.
* If case insensitive, it already is lowercased.
*
* @param {Object} resource
* The resource provided in the {@link jQuery.betterAutocomplete} init
* constructor.
*
* @param {Boolean} caseSensitive
* From options.caseSensitive, the searching should be case sensitive.
*
* @returns {Array[Object]}
* A flat array containing pure result objects. May be an empty array.
*/
queryLocalResults: function(query, resource, caseSensitive) {
if (!$.isArray(resource)) {
// Per default Better Autocomplete only handles arrays
return [];
}
var results = [];
$.each(resource, function(i, value) {
switch ($.type(value)) {
case 'string': // Flat array of strings
if ((caseSensitive ? value : value.toLowerCase())
.indexOf(query) >= 0) {
// Match found
results.push({ title: value });
}
break;
case 'object': // Array of result objects
if ($.type(value.title) == 'string' &&
(caseSensitive ? value.title : value.title.toLowerCase())
.indexOf(query) >= 0) {
// Match found in title field
results.push(value);
}
else if ($.type(value.description) == 'string' &&
(caseSensitive ? value.description :
value.description.toLowerCase()).indexOf(query) >= 0) {
// Match found in description field
results.push(value);
}
break;
}
});
return results;
},
/**
* Fetch remote result data and return it using completeCallback when
* fetching is finished. Must be asynchronous in order to not freeze the
* Better Autocomplete instance.
*
* <br /><br /><em>Default behavior: Fetches JSON data from the url, using
* the jQuery.ajax() method. Errors are ignored.</em>
*
* @param {String} url
* The URL to fetch data from.
*
* @param {Function} completeCallback
* This function must be called, even if an error occurs. It takes zero
* or one parameter: the data that was fetched.
*
* @param {Number} timeout
* The preferred timeout for the request. This callback should respect
* the timeout.
*
* @param {Boolean} crossOrigin
* True if a cross origin request should be performed.
*/
fetchRemoteData: function(url, completeCallback, timeout, crossOrigin) {
$.ajax({
url: url,
dataType: crossOrigin && !$.support.cors ? 'jsonp' : 'json',
timeout: timeout,
success: function(data, textStatus) {
completeCallback(data);
},
error: function(jqXHR, textStatus, errorThrown) {
completeCallback();
}
});
},
/**
* Process remote fetched data by extracting an array of result objects
* from it. This callback is useful if the fetched data is not the plain
* results array, but a more complicated object which does contain results.
*
* <br /><br /><em>Default behavior: If the data is defined and is an
* array, return it. Otherwise return an empty array.</em>
*
* @param {mixed} data
* The raw data recieved from the server. Can be undefined.
*
* @returns {Array[Object]}
* A flat array containing result objects. May be an empty array.
*/
processRemoteData: function(data) {
if ($.isArray(data)) {
return data;
}
else {
return [];
}
},
/**
* From a given result object, return it's group name (if any). Used for
* grouping results together.
*
* <br /><br /><em>Default behavior: If the result has a "group" property
* defined, return it.</em>
*
* @param {Object} result
* The result object.
*
* @returns {String}
* The group name, may contain HTML. If no group, don't return anything.
*/
getGroup: function(result) {
if ($.type(result.group) == 'string') {
return result.group;
}
},
/**
* Called when remote fetching begins.
*
* <br /><br /><em>Default behavior: Adds the CSS class "fetching" to the
* input field, for styling purposes.</em>
*
* @param {Object} $input
* The input DOM element, wrapped in jQuery.
*/
beginFetching: function($input) {
$input.addClass('fetching');
},
/**
* Called when fetching is finished. All active requests must finish before
* this function is called.
*
* <br /><br /><em>Default behavior: Removes the "fetching" class.</em>
*
* @param {Object} $input
* The input DOM element, wrapped in jQuery.
*/
finishFetching: function($input) {
$input.removeClass('fetching');
},
/**
* Executed after the suggestion list has been shown.
*
* @param {Object} $results
* The suggestion list UL element, wrapped in jQuery.
*
* <br /><br /><em>Default behavior: Does nothing.</em>
*/
afterShow: function($results) {},
/**
* Executed after the suggestion list has been hidden.
*
* @param {Object} $results
* The suggestion list UL element, wrapped in jQuery.
*
* <br /><br /><em>Default behavior: Does nothing.</em>
*/
afterHide: function($results) {},
/**
* Construct the remote fetching URL.
*
* <br /><br /><em>Default behavior: Adds "?q=query" to the path. The query
* string is URL encoded.</em>
*
* @param {String} path
* The path given in the {@link jQuery.betterAutocomplete} constructor.
*
* @param {String} query
* The raw query string. Remember to URL encode this to prevent illegal
* character errors.
*
* @returns {String}
* The URL, ready for fetching.
*/
constructURL: function(path, query) {
return path + '?q=' + encodeURIComponent(query);
},
/**
* To ease up on server load, treat similar strings the same.
*
* <br /><br /><em>Default behavior: Trims the query from leading and
* trailing whitespace.</em>
*
* @param {String} rawQuery
* The user's raw input.
*
* @param {Boolean} caseSensitive
* Case sensitive. Will convert to lowercase if false.
*
* @returns {String}
* The canonical query associated with this string.
*/
canonicalQuery: function(rawQuery, caseSensitive) {
var query = $.trim(rawQuery);
if (!caseSensitive) {
query = query.toLowerCase();
}
return query;
},
/**
* Insert the results list into the DOM and position it properly.
*
* <br /><br /><em>Default behavior: Inserts suggestion list directly
* after the input element and sets an absolute position using
* jQuery.position() for determining left/top values. Also adds a nice
* looking box-shadow to the list.</em>
*
* @param {Object} $results
* The UL list element to insert, wrapped in jQuery.
*
* @param {Object} $input
* The text input element, wrapped in jQuery.
*/
insertSuggestionList: function($results, $input) {
$results.width($input.outerWidth() - 2) // Subtract border width.
.css({
position: 'absolute',
left: $input.position().left,
top: $input.position().top + $input.outerHeight(),
zIndex: 10,
maxHeight: '330px',
// Visually indicate that results are in the topmost layer
boxShadow: '0 0 15px rgba(0, 0, 0, 0.5)'
})
.hide()
.insertAfter($input);
}
};
/*
* jQuery focus selector, required by Better Autocomplete.
*
* @see http://stackoverflow.com/questions/967096/using-jquery-to-test-if-an-input-has-focus/2684561#2684561
*/
var filters = $.expr[':'];
if (!filters.focus) {
filters.focus = function(elem) {
return elem === document.activeElement && (elem.type || elem.href);
};
}
})(jQuery);