Skip to content

Instantly share code, notes, and snippets.

@dhchow
Created January 30, 2012 23:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dhchow/1707422 to your computer and use it in GitHub Desktop.
Save dhchow/1707422 to your computer and use it in GitHub Desktop.
AutoSuggest.js
/**
This component suggests items in a drop-down list as the user types.
If an item is selected, the input box is populated, potentially with multiple entries.
Very similar to the Facebook geo targeting UI. Also listens for up and down keystrokes
to navigate the list.
new cotweet.components.AutoSuggest($element, options)
options = {
data: array of { } // Pre-loaded data
remote: { // For hitting the server on every-ish keystroke
url: string // URL to hit for results
params: hash // params for the URL, the user's input will be appended to this
minChars: number // minimum number of characters user must type before we hit the server
timeout: number // number of milliseconds between calls if the user types furiously
afterInitialLoad: function // called after the first remote ajax called is made and the list is loaded w/data
},
maxEntries: number // max number of entries to show in suggestions list
emptyListHtml: string
hideByDefault: boolean
onSelect: function // called after user selects an item from the list
onRemove: function // called after user removes an item from the list
subHtml: function(item) // called as items are added to the suggestion list, should return html string
}
options.data = [{
id: string
name: string // to search
html: string // html to
}]
*/
cotweet.components.AutoSuggest = Class.extend({
init: function(element, options) {
this.container = $(element);
$(tmpl("autosuggest_template", {input_id: this.container.attr("id") + "_input"})).appendTo( this.container );
this.options = {
remote: {
minChars: 1,
timeout: 500
},
maxEntries: 10,
emptyListHtml: "",
invalidEntryText: 'Not a valid entry',
hideByDefault: false
}
this.options = $.extend(true, this.options, options)
this.data = this.options.data;
this.notifier = $({owner: this});
this.input = $("input.auto-suggest-input", this.container);
new $.GrowingInput(this.input, {}); // Allows the input to resize based on content
this.field = $(".auto-suggest-field", this.container)
this.selectionList = $(".auto-suggest-selected", this.container)
this.suggestionList = $(".auto-suggest-list", this.container)
this.errorMessages = $(".auto-suggest-errors", this.container)
this.addListeners();
this.validator = new cotweet.components.Validator({
validations: {
invalidAutoSuggestEntry: {
errorMessage: this.options.invalidEntryText,
test: $.proxy(function() { return ($.trim(this.input.val()) == '') ? true : false; }, this)
}
},
onSuccess: $.proxy(function() {
// hide error element and remove all error messages
this.errorMessages.hide().html('');
}, this),
onFailure: $.proxy(function() {
// remove all previous error messages
this.errorMessages.html('');
// add new error messages
$.each(this.validator.getErrors(), $.proxy(function(index, message) {
var text = (this.errorMessages.html()) ? this.errorMessages.html() + '<br />' + message : message;
this.errorMessages.html(text);
}, this));
// show error element
this.errorMessages.show();
}, this)
});
this.options.hideByDefault ? this.container.hide() : this.container.show();
},
addListeners: function() {
var self = this;
$(".auto-suggest-box", this.container).click(function(){
$(this).children(".auto-suggest-input").show().focus()
})
// Keydown to prevent default behavior on some keys, and better feedback :)
this.input.keydown($.proxy(function(ev){
var $current;
switch(ev.which) {
case 38: // up
$current = $(".highlight", this.suggestionList)
if ( !$current.length ) this.highlightFirstSuggestion()
if ( $current.prev().length )
$current.removeClass("highlight").prev().addClass("highlight")
ev.preventDefault()
break;
case 40: // down
$current = $(".highlight", this.suggestionList)
if ( !$current.length ) this.highlightFirstSuggestion()
if ( $current.next().length )
$current.removeClass("highlight").next().addClass("highlight")
ev.preventDefault()
break;
case 13: // enter
var $highlighted = $(".highlight", this.suggestionList)
this.container.trigger("addToSelections", $highlighted.data("id"))
this.input.show().focus();
break;
case 8: // backspace
if (!this.input.val()) {
ev.preventDefault(); // skip browser 'back'
var $lastSelection = $(".auto-suggest-selected-item:last", this.selectionList)
if ($lastSelection.hasClass("highlight")) {
$lastSelection.remove()
this.notifier.trigger('change');
if (this.options.onRemove) this.options.onRemove(ev, $lastSelection)
} else {
$lastSelection.addClass("highlight")
}
}
// New thread so that input value is populated before we refresh
setTimeout($.proxy(this.refreshList, this), 0)
break;
default:
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
// New thread so that input value is populated before we refresh
setTimeout($.proxy(this.refreshList, this), 0)
}
}, this))
this.input.focus($.proxy(function(ev){
var autosuggest = this;
var focus = function() {
autosuggest.field.addClass("focus")
autosuggest.filter(autosuggest.input.val())
}
// make sure focus happens after the blur
if (autosuggest.blur) setTimeout(focus, 555)
else focus()
}, this))
this.input.blur((function(autosuggest){
return function(ev){
autosuggest.notifier.trigger('blur');
autosuggest.blur = true
setTimeout(function(){
//if (!autosuggest.selected) autosuggest.suggestionList.empty().hide()
autosuggest.selected = false
autosuggest.field.removeClass("focus")
autosuggest.blur = false
autosuggest.suggestionList.hide()
}, 300)
}
})(this))
this.suggestionList.delegate(".list-entry", "click", function(){
self.selected = true;
self.container.trigger("addToSelections", $(this).data("id"));
self.input.show().focus();
});
this.suggestionList.delegate(".list-entry", "mouseover mouseout", function(ev) {
if (ev.type == "mouseover")
$(this).addClass("highlight")
else if (ev.type == "mouseout")
$(this).removeClass("highlight")
});
this.selectionList.delegate(".auto-suggest-selected-item", "click", function(ev){
if (ev.target.className == "remove"){
$(this).remove();
self.notifier.trigger('change');
if (self.options.onRemove) self.options.onRemove(ev, $(this));
}
})
this.container.bind({
addToSelections: function(ev, itemId) {
self.suggestionList.empty().hide()
self.input.val("")
self.addToSelections(itemId)
self.notifier.trigger('change');
if (self.options.onSelect) self.options.onSelect(ev, itemId)
}
});
},
select: function(ids) {
if (ids) {
$.each(ids, $.proxy(function(index, id) {
this.trigger("addToSelections", id);
}, this));
}
},
/**
* Refresh the list based on user's input using either remote data or local data,
* depending on the autosuggest's config
*/
refreshList: function() {
if (this.options.remote.url)
this.tryRemote()
else
this.filter(this.input.val())
},
/**
* Fetches remote data if there is sufficient input in the field
* if timeout rate is satisfied
*/
tryRemote: function() {
if (!this.options.remote.url) return;
if (this.input.val().length >= this.options.remote.minChars && !this.remoteQueue) {
this.remoteQueue = true
setTimeout($.proxy(function(){ this.fetchData(true) }, this), this.options.remote.timeout)
}
},
/**
* Fetch the data remotely if options.remote params are set
*/
fetchData: function(typing, callback) {
if (!this.options.remote.url) return;
$.ajax({
url: this.options.remote.url,
data: $.extend(this.options.remote.params || {}, {name: this.input.val()}),
success: $.proxy(function(data, textStatus, $xhr) {
this.remoteQueue = false
if ($.isArray(data))
this.data = data
else {
// This will do something funky if there are multiple vars in data.
// Sort of relying on there being a single wrapper here.
for (var key in data)
this.data = data[key]
}
this.filter(this.input.val())
if (!typing && this.options.remote.afterInitialLoad) this.options.remote.afterInitialLoad()
if (callback) callback();
}, this),
error: $.proxy(function(response){
cotweet.log("Error getting autosuggest results for " + this.options.remote.url + ": ", response);
}, this)
})
},
highlightFirstSuggestion: function() {
$(".list-entry", this.suggestionList).removeClass("highlight")
$(".list-entry:first", this.suggestionList).addClass("highlight")
},
filter: function(s) {
//console.log("filtering", s, this.data)
this.suggestionList.show()
var available = [];
if (this.data == null) return;
if (s != null && s.length > 0) {
for (var i=0;i<this.data.length;i++) {
if (this.data[i].name.toLowerCase().startsWith(s.toLowerCase())) {
available.push(this.data[i]);
}
}
}
this.suggestionList.empty();
if (!available.length) {
if (this.input.val())
this.suggestionList.append("<li class='empty'>"+ _("NoMatchesFound") +"</li>");
else if (!this.selectionList.children().length)
this.suggestionList.append("<li class='empty'>"+ this.options.emptyListHtml +"</li>");
}
for (var i=0;i<available.length && i< this.options.maxEntries;i++) {
var l = StringUtil.htmlEntities(available[i].name), a = [l];
if (s != null && s.length > 0) {
var start = l.toLowerCase().indexOf(s.toLowerCase());
a = [];
if (start > 0) a.push(l.substr(0, start));
a.push("<strong>" + l.substr(start, s.length) + "</strong>");
if (start + s.length < l.length) a.push(l.substr(start + s.length));
}
this.addToSuggestions({ id: available[i].id, name: l, html: a.join(""), subHtml: (this.options.subHtml ? this.options.subHtml(available[i]) : "") })
}
this.highlightFirstSuggestion()
},
/**
* attrs = { id: number, name: string }
*/
addToSuggestions: function(item) {
var $item = $(tmpl('<li id="auto_suggest_list_entry_<%=id%>" class="list-entry"><%=html%><div class="list-entry-sub"><%=subHtml%></div></li>', {id: item.id, html: item.html, subHtml: item.subHtml}))
$item.data("id", item.id)
$item.data("name", item.name)
$item.data("subHtml", item.subHtml)
this.suggestionList.append($item);
return $item
},
findItem: function(id) {
if (!this.data) throw new Error("No data to find item with id " + id);
for (var i = this.data.length; i--;) {
if (this.data[i].id == id)
return this.data[i];
}
},
addDataItem: function(item) {
this.data.push(item)
},
/**
* Add an item to the selected list
*/
addToSelections: function(itemId) {
if (!itemId) return;
var item = this.findItem(itemId);
// Prevent duplicates
if ($(".auto-suggest-selected-item input[value='"+ item.id +"']", this.selectionList).length) return;
// Create and append the item
var $item = $(tmpl('<li class="auto-suggest-selected-item"><%=name%><em class="remove">x</em><input type="hidden" value="<%=id%>"/></li>', {id: item.id, name: item.name}))
$item.data("name", item.name)
$item.data("id", item.id)
if (this.options.subHtml) {
$item.data("sub", this.options.subHtml(item))
}
this.selectionList.append($item)
return $item
},
val: function() {
var ids = []
$(".auto-suggest-selected-item input", this.selectionList).each(function() {
ids.push($(this).val())
})
return ids;
},
allSuggestionsSize: function() {
return this.data.length;
},
filteredSuggestionsSize: function() {
return this.suggestionList.children(".list-entry").length;
},
getString: function() {
var names = []
$(".auto-suggest-selected-item", this.selectionList).each(function() {
var subHtml = $(this).data("sub")
var name = $(this).data("name")
if(subHtml && subHtml.length > 0)
name += ", " + subHtml
names.push(name)
})
return names.join("; ");
},
initialValue: function() {
var value = this.container.data("initial-value");
return value ? value + "" : "";
},
loaded: function(loaded) {
return loaded ? this.container.data("loaded", loaded) : this.container.data("loaded");
},
htmlData: function(name) {
return this.container.data(name);
},
setData: function(data) { this.data = data },
trigger: function(eventName, data) { this.container.trigger(eventName, data); },
clearSelections: function() { $(".auto-suggest-selected-item", this.selectionList).remove(); this.input.val(''); this.notifier.trigger('change'); },
show: function() { this.container.show(); return this; },
hide: function() { this.container.hide(); return this; },
focus: function() { this.input.focus(); return this; }
});
cotweet.components.DropDown = Class.extend({
init: function(list, options) {
this.list = list;
this.options = {timer: true, time: 250, keepOpen: false };
$.extend(true, this.options, options);
var c = this;
this.list.mouseover(function(e) {
c.inHandler()
}).mouseout(function(e) {
c.outHandler()
});
},
expand: function() {
this.list.show();
},
collapse: function() {
this.list.hide();
},
inHandler: function() {
!this.options.keepOpen ? (this.options.timer ? this.stopHideTimer() : this.expand()) : false;
},
outHandler: function(){
!this.options.keepOpen ? (this.options.timer ? this.startHideTimer() : this.collapse()) : false;
},
startHideTimer: function() {
var c = this;
this.timer = $.timer(this.options.time, function(t){
if (c.list.is(":visible"))
c.collapse();
t.stop();
t = null;
});
},
stopHideTimer: function() {
if (this.timer)
this.timer.stop();
this.timer = null;
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment