Skip to content

Instantly share code, notes, and snippets.

@tkh44
Last active December 27, 2015 03:09
Show Gist options
  • Select an option

  • Save tkh44/7257222 to your computer and use it in GitHub Desktop.

Select an option

Save tkh44/7257222 to your computer and use it in GitHub Desktop.
Small AutoComplete Library for doing live searches using Backbone collections and JST templates. It uses a basic localStorage cache to try to reduce requests. We use hammer.js for the 'tap' event since we use this is mostly for mobile.
;(function($, window, document, undefined) {
var pluginName = 'AutoComplete',
defaults = {
list: '.auto-complete-list',
cancel: '.auto-complete-cancel',
itemSelector: '.auto-complete-item',
dataId: 'auto-val-set',
valueAttr: 'name',
valueId: 'id',
collection: new Backbone.Collection(),
/**
* Function to build out our data sent in the $.ajax request
* @returns {Object}
*/
buildDataFn: $.noop,
// Name of the JST template to be appended
resultItemTemplate: 'autocomplete-item',
// Function to be called when there are no results
emptyFn: $.noop
},
ls = window.localStorage;
function AutoComplete(element, options) {
this.options = _.defaults(options, defaults);
this.$input = $(element);
this.$list = $(options.list);
this.$cancel = $(options.cancel);
this.$otherInputs = this.$input.closest('form').find('input, select, textarea').not(this.$input);
this._name = this.options.name || this.$input.attr('name') || '';
this._previousSearch = '';
this._ttl = '';
this.init();
}
AutoComplete.prototype = {
/**
* Entry Point
*/
init: function() {
var functions = _.functions(this);
functions.unshift(this);
_.bindAll.apply(_, functions);
this.setupEvents();
},
/**
* Setup and attach event handlers
*/
setupEvents: function() {
this.$input.on('keyup', this.liveSearch);
this.$input.on('focus blur', this.setupLiveSearch);
this.$otherInputs.on('focus', this.setupLiveSearch);
this.$cancel.on('tap', this.resetAutoComplete);
this.$list.delegate(this.options.itemSelector, 'tap', this.fillInputValue);
},
/**
* Handle events
* Show/Hide the auto complete list and cancel button
* @param e
*/
setupLiveSearch: function(e) {
// If we are focusing out to another form element hide the autocomplete list
if (this.$otherInputs.is(':focus') || (e.type === "blur" && !this.$input.is(':focus'))) {
this.hideAutoCompleteList();
} else {
// We let the webview do its focus/unfocus thing before we mess with the DOM
// This seems to help mitigate ui jerks
this.updateListStyle(this.$input, this.$list);
this.$list.show();
}
},
/**
* Fill the input when a list item is tapped
* @param e
*/
fillInputValue: function(e) {
var $target = $(e.currentTarget);
this.$input
.val($target.data(this.options.valueAttr))
.data(this.options.dataId, this.$input.data(this.options.valueId));
this.$list.hide();
},
/**
* Reset AutoComplete back to base state
*/
resetAutoComplete: function() {
this.$input.val('').data(this.options.dataId, '');
this.$cancel.hide();
this.hideAutoCompleteList();
},
/**
* Hide the AutoComplete list
*/
hideAutoCompleteList: function() {
// Reset the collection so we don't have stale data
this.$list
.hide()
.empty();
},
/**
* Set the max-height of the AutoComplete list so that it stays scrollable
* and viewable on screen
*
* @param $input
* @param $list
*/
updateListStyle: function($input, $list) {
var scrollTop = $input.offset().top,
windowHeight = window.innerHeight;
$list.css({
'max-height': (windowHeight - (scrollTop + $input.outerHeight(true))) + 'px'
});
},
liveSearch: _.debounce(function() {
var self = this,
$input = this.$input,
$list = this.$list,
$cancel = this.$cancel,
value = $.trim($input.val()).toLowerCase(),
listCollection = this.options.collection,
data;
// Prevent search firing after debounce if the input field is cleared (backspace, 'x' button)
if (value.length < 1) {
$list.hide();
$cancel.hide();
return;
}
/**
* Success callback for our live search call
* We have to pull some shenanigans to keep collection in order as defined by its
* comparator function
*
* @param collection
* @param res
* @param options
*/
function successCallback(collection, res, options) {
buildList(res, options.previousModels);
}
function errorCallback(col, res, options) {
console.log('error');
$input.removeClass('input-loading');
self.$cancel.show();
}
function buildList(list, previousModels) {
if (previousModels) {
listCollection.remove(previousModels);
_.each(previousModels, self.autoCompleteRemove);
} else {
listCollection.reset([]);
self.$list.empty();
}
if (list.length) {
listCollection.add(list);
listCollection.each(self.autoCompleteAppend);
} else {
self.showEmptySearch();
}
self._previousSearch = value;
self.setObject(self.prefix('query'), list);
$input.removeClass('input-loading');
self.$cancel.show();
}
function searchRooms() {
data = self.options.buildDataFn();
listCollection.fetch({
data: data,
reset: true,
success: successCallback,
error: errorCallback
});
}
self.$cancel.hide();
$input.addClass('input-loading');
if (!this.isExpired() && self.keyExists(value)) {
buildList(this.getObject(self.prefix('query')));
} else {
searchRooms();
}
}, 500),
/**
* Remove and item from the list using the data-id attribute
* @param item
*/
autoCompleteRemove: function(item) {
var id = item.get('id');
$('[data-id=' + id + ']', this.$list).remove();
},
/**
* Append and item from the list using the data-id attribute
* @param item
*/
autoCompleteAppend: function(item) {
var $list = this.$list,
template = this.options.resultItemTemplate,
html = JST[template](item.toJSON());
$list
.append(html)
.show();
},
/**
* Callback function to be called when there is no search results
*/
showEmptySearch: function() {
console.log('no search results');
if (this.options.emptyFn && _.isFunction(this.options.emptyFn)) {
this.options.emptyFn.apply(this);
}
},
/*************************************
* Local Storage utilities for cache
*************************************/
/**
* prefix local storage key with our namespace
* @param key
* @returns {string}
*/
prefix: function(key) {
return "__" + this._name + "__" + key;
},
/**
* Get the current time in milliseconds
* @returns {number}
*/
now: function() {
return new Date().getTime();
},
/**
* Check and see if our value is older than 24 hours
* @returns {boolean}
*/
isExpired: function() {
var ttl = ls.getItem(this.prefix('ttl'));
return _.isFinite(ttl) && this.now() > ttl ? true : false;
},
keyExists: function(value) {
return (value === this._previousSearch.toLowerCase())
&& (this.getObject(this.prefix('query')) !== null);
},
/**
* Thin wrapper to allow storage of objects in local storage
* **Value must be object with strings as keys!**
* @param key
* @param value
*/
setObject: function(key, value) {
ls.setItem(this.prefix('ttl'), this.now() + 24 * 60 * 60 * 1e3 ); // 24hours
ls.setItem(key, JSON.stringify(value));
},
/**
* Thin wrapper to allow retrieval of objects(json) from local storage
* @param key
* @returns {object}
*/
getObject: function(key) {
var value = ls.getItem(key);
return value && JSON.parse(value);
}
};
/**
* jQuery plugin definition
* @param options
* @returns {*}
*/
$.fn[pluginName] = function(options) {
return this.each(function() {
if (!$.data(this, 'plugin_' + pluginName)) {
$.data(this, 'plugin_' + pluginName, new AutoComplete(this, options));
}
});
}
})(jQuery, window, document);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment