backbone.typeahead.js
Version:
A Bootstrap inspired Typeahead for Backbone.js
284 lines (269 loc) • 9.85 kB
JavaScript
(function(Backbone, _, $) {
'use strict';
var Typeahead = function(models, options) {
// The first parameter 'model' is an optional parameter
// If given an array, a copy will be made and passed to preInitialize
if (_.isArray(models)) {
// It is copied to prevent overwrite
// TODO this copy may be unnecessary
models = models.slice(0);
if (!_.isObject(options)) {options = {};}
} else if (_.isObject(models)) {
options = models;
// TODO Should also error if no collection was provided in options
} else {
throw new Error('A typeahead must be created with either initial models or have its collection specified in an options object');
}
// Initialize
this.preInitialize.call(this, models, options);
Backbone.View.call(this, options);
this.postInitialize.call(this);
};
Typeahead.VERSION = '0.3.0';
Typeahead.extend = Backbone.View.extend;
// TODO Use a preInit/postInit style view
Typeahead.ItemView = Backbone.View.extend({
tagName: 'li',
className: 'typeahead-item',
events: {
'click': 'selectItem',
'mouseover': 'activateItem'
},
initialize: function(options) {
// A reference to the parent view (aka the Typeahead) is required
this.parent = options.parent;
},
render: function() {
// TODO Template should be cached
this.$el.html(_.template(this.template)(this.model.toJSON()));
return this;
},
// TODO Or use triggers instead?
selectItem: function() {
this.parent.selectModel(this.model);
},
activateItem: function() {
this.parent.activateModel(this.model);
}
});
_.extend(Typeahead.prototype, Backbone.View.prototype, {
// Pre-initialization should set up the collection type (remote / local)
preInitialize: function(models, options) {
// Set sane defaults
// TODO Allow compound keys? Introspect first model for a default key?
options.key = options.key || 'name';
options.limit = options.limit || 8;
if (_.isUndefined(options.collection) && _.isArray(models)) {
// TODO Any properties of collections that options can't handle?
options.collection = new Backbone.Collection(models, options);
}
// Build a item view if one was not provided
// TODO confirm that any given view is a Backbone.View object
this.view = options.view;
if (_.isUndefined(this.view)) {
// TODO provide the partially extended view for users to extend
this.view = Typeahead.ItemView.extend({
// Dynamically construct a template with the key
// TODO Allow the item template to be passed through options
template: options.itemTemplate || '<a><%- ' + options.key + ' %></a>'
});
}
// Attach native events, deferring to custom events
this.events = _.extend({}, this.nativeEvents, _.result(this, 'events'));
// Backbone 1.1 no longer binds options to this by default
this.options = options;
// TODO Listen to changes on the collection
},
// Models were emptied by the preInitialization function
// The parent Backbone.View constructor function has finished
// Options have already been parsed by _context()
postInitialize: function() {
// TODO Build the cached lookup of models by the given key, which must
// be updated when the collection changes and an option to turn off
this.results = []; // matched results as an array of Backbone Models
// Boolean toggles
this.focused = false; // Is the input in focus?
this.shown = false; // Is the menu shown?
this.mousedover = false; // Is the mouse over the typeahead (incl. menu)?
},
template: '<input type="text" class="form-control" placeholder="Search" /><ul class="dropdown-menu"></ul>',
nativeEvents: {
'keyup': 'keyup',
'keypress': 'keypress',
'keydown': 'keydown',
'blur input': 'blur',
'focus input': 'focus',
'mouseenter': 'mouseenter',
'mouseleave': 'mouseleave'
},
// The render function should be overwritten on extended typeaheads
render: function() {
this.$el.html(this.template);
this.$menu = this.$('ul');
this.$input = this.$('input');
return this;
},
// Called by searchInput and whenever models change
rerender: function(models) {
this.$menu.empty();
_.each(models, this.renderModel, this);
models.length ? this.show() : this.hide();
},
renderModel: function(model) {
// TODO Do not re-render views?
this.$menu.append((new this.view({model: model, parent: this})).render().el);
},
// Return the models with a key that matches a portion of the given value
// TODO compound keys! cached values! the kitchen sink!
search: function(value) {
// Sanitize the input before performing regex search.
var sanitizedValue = value.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
// Use a regex to quickly perform a case-insensitive match
var re = new RegExp(sanitizedValue, 'i');
var key = this.options.key;
return this.collection.filter(function(model) {
return re.test(model.get(key));
});
},
// Convenience method for clearing the search input
clearInput: function() {
this.$input.val('');
},
// Pull the value from the search input and re-render the matched models
searchInput: function() {
// TODO it'd be nice to limit the results during array iteration
this.results = this.search(this.$input.val()).slice(0, this.options.limit);
this.rerender(this.results);
},
select: function() {
// TODO This may go wrong with different templates
var index = this.$menu.find('.active').index();
this.selectModel(this.results[index]);
},
selectModel: function(model) {
// Update the input field with the key attribute of the select model
this.$input.val(model.get(this.options.key));
// Hide the menu
this.hide();
// TODO What other parameters should be in the trigger?
this.trigger('selected', model, this.collection);
// Empty the results
this.results = [];
},
activateModel: function() {
this.$menu.find('.active').removeClass('active');
},
// Misc. events
keyup: function(evt) {
switch(evt.keyCode) {
case 40: // Down arrow
case 38: // Up arrow
case 16: // Shift
case 17: // Ctrl
case 18: // Alt
break;
// case 9: // Tab - disabled to prevent rogue select on tabbed focus
// TODO tab should also leave focus
case 13: // Enter
// TODO shown needs to be returned to its original function (as an
// indicator of whether the menu is currently displayed or not)
if (!this.shown) {return;}
this.select();
break;
case 27: // escape
if (!this.shown) {return;}
this.hide();
break;
default:
this.searchInput();
}
evt.stopPropagation();
evt.preventDefault();
},
// Menu state
focus: function() {
this.focused = true;
// TODO Only show the menu if no item has been selected
if (!this.shown) {this.show();}
},
blur: function() {
this.focused = false;
if (!this.mousedover && this.shown) {this.hide();}
},
mouseenter: function() {
this.mousedover = true;
// TODO Re-add 'active' class to the current target
},
mouseleave: function() {
this.mousedover = false;
if (!this.focused && this.shown) {this.hide();}
},
// Allow the user to change their selection with the keyboard
keydown: function(evt) {
// TODO I still hate this array check
this.suppressKeyPressRepeat = ~$.inArray(evt.keyCode, [40,38,9,13,27]);
this.move(evt);
},
keypress: function(evt) {
// The suppressKeyPressRepeat check exists because keydown and keypress
// may fire for the same event
if (this.suppressKeyPressRepeat) {return;}
this.move(evt);
},
move: function(evt) {
if (!this.shown) {return;}
switch(evt.keyCode) {
case 9: // Tab
case 13: // Enter
case 27: // Escape
evt.preventDefault();
break;
case 38: // Up arrow
evt.preventDefault();
this.prevItem();
break;
case 40: // Down arrow
evt.preventDefault();
this.nextItem();
break;
}
evt.stopPropagation();
},
prevItem: function() {
// TODO should there be signals for prev and next?
var active = this.$menu.find('.active').removeClass('active');
var prev = active.prev();
if (!prev.length) {prev = this.$menu.find('li').last();}
prev.addClass('active');
},
nextItem: function() {
var active = this.$menu.find('.active').removeClass('active');
var next = active.next();
if (!next.length) {next = this.$menu.find('li').first();}
next.addClass('active');
},
// Show or hide the menu depending on the typeahead's state
show: function() {
// DO not show if there are no results
if (!this.results.length) {return;}
var pos = $.extend(
{},
this.$input.position(),
// Calling the [0] index returns the vanilla HTML object
{height: this.$input[0].offsetHeight}
);
this.$menu.css({
top: pos.top + pos.height,
left: pos.left
}).show();
this.shown = true;
return this;
},
hide: function() {
this.$menu.hide();
this.shown = false;
return this;
}
});
Backbone.Typeahead = Typeahead;
})(Backbone, _, $);