Created
January 30, 2012 23:15
-
-
Save dhchow/1707422 to your computer and use it in GitHub Desktop.
AutoSuggest.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
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