backbone.asyncautocomplete
Advanced tools
Comparing version 0.1.2 to 1.0.0
{ | ||
"name": "backbone.asyncautocomplete", | ||
"main": "main.js", | ||
"version": "0.1.2", | ||
"version": "1.0.0", | ||
"authors": [ | ||
@@ -19,3 +19,4 @@ "Carl Törnqvist <calle.tornqvist@gmail.com>" | ||
"test", | ||
"tests" | ||
"tests", | ||
"example" | ||
], | ||
@@ -31,4 +32,2 @@ "description": "Autocomplete with async support as Backbone View", | ||
"async", | ||
"datalist", | ||
"pollyfill", | ||
"autocomplete" | ||
@@ -35,0 +34,0 @@ ], |
@@ -8,35 +8,50 @@ var Backbone = require('backbone'); | ||
var AsyncCollection = Backbone.Collection.extend({ | ||
url: 'http://jsonplaceholder.typicode.com/users' | ||
}); | ||
(function () { | ||
'use strict'; | ||
var MyItem = Autocomplete.Item.extend({ | ||
foo: 'bar', | ||
template: _.template('<li class="Autocomplete-item<% if (data.isSelected) { %> is-selected<% } %>"><%= data.name %> <em>(<%= data.username %>)</em></li>') | ||
}); | ||
var AsyncCollection = Backbone.Collection.extend({ | ||
url: 'http://jsonplaceholder.typicode.com/users' | ||
}); | ||
var Names = Autocomplete.define({ | ||
Item: MyItem, | ||
filterAttr: 'name', | ||
}).extend({ | ||
template: _.template('<ul class="Autocomplete" />') | ||
}); | ||
var MyItem = Autocomplete.Item.extend({ | ||
initialize: function () { | ||
_.bindAll(this, 'whenCandidate'); | ||
var Usernames = Autocomplete.define({ | ||
Item: MyItem, | ||
searchAttr: 'username', | ||
filterAttr: 'username', | ||
wait: 400 | ||
}).extend({ | ||
template: _.template('<ul class="Autocomplete" />') | ||
}); | ||
this.listenTo(this.model, { | ||
'change:isCandidate': this.whenCandidate | ||
}); | ||
}, | ||
new Names({ | ||
el: Backbone.$('#input_static'), | ||
collection: new Backbone.Collection(data) | ||
}); | ||
whenCandidate: function (model, value, options) { | ||
this.$el.toggleClass('is-candidate', value); | ||
}, | ||
new Usernames({ | ||
el: Backbone.$('#input_async'), | ||
collection: new AsyncCollection() | ||
}); | ||
template: _.template('<li class="Autocomplete-item<% if (isSelected) { %> is-selected<% } if (isCandidate) { %> is-candidate<% } %>"><%= name %> <em>(<%= username %>)</em></li>') | ||
}); | ||
var Names = Autocomplete.define({ | ||
Item: MyItem, | ||
filterAttr: 'name', | ||
}).extend({ | ||
template: _.template('<ul class="Autocomplete" />') | ||
}); | ||
var Usernames = Autocomplete.define({ | ||
Item: MyItem, | ||
searchAttr: 'username', | ||
filterAttr: 'username', | ||
wait: 400 | ||
}).extend({ | ||
template: _.template('<ul class="Autocomplete" />') | ||
}); | ||
new Names({ | ||
el: Backbone.$('#input_static'), | ||
collection: new Backbone.Collection(data) | ||
}); | ||
new Usernames({ | ||
el: Backbone.$('#input_async'), | ||
collection: new AsyncCollection() | ||
}); | ||
}()); |
@@ -24,6 +24,13 @@ (function (root, factory) { | ||
*/ | ||
var SELECTED = 'selectedId'; | ||
var IS_SELECTED = {'isSelected': true}; | ||
var IS_CANDIDATE = {'isCandidate': true}; | ||
var IS_EXPANDED = {'aria-expanded': true}; | ||
var NOT_SELECTED = {'isSelected': false}; | ||
var NOT_CANDIDATE = {'isCandidate': false}; | ||
var NOT_EXPANDED = {'aria-expanded': false}; | ||
var FILTER_ATTR = 'label'; | ||
var SEARCH_ATTR = 'search'; | ||
var WAIT = 250; | ||
var LIMIT = false; | ||
var THRESHOLD = 2; | ||
@@ -55,2 +62,3 @@ var EVENT_MAP = { | ||
wait : WAIT, | ||
limit : LIMIT, | ||
filterAttr : FILTER_ATTR, | ||
@@ -66,3 +74,3 @@ searchAttr : SEARCH_ATTR, | ||
constructor: function (options) { | ||
_.bindAll(this, 'search', 'onSync', 'onRequest', 'onError', 'filter', 'fetchData', 'onSelect'); | ||
_.bindAll(this, 'search', 'onSync', 'fetchData', 'onSelect'); | ||
@@ -93,5 +101,5 @@ options = (options || {}); | ||
'sync': this.onSync, | ||
'error': this.onError, | ||
'request': this.onRequest, | ||
'change:isSelected': this.onSelect | ||
'error': this.render, | ||
'change:isSelected': this.onSelect, | ||
'change:isCandidate': this.whenCandidate | ||
}); | ||
@@ -103,7 +111,23 @@ | ||
/** | ||
* Filter out mathching items | ||
* Hijack `setElement` method to assign ARIA attributes | ||
*/ | ||
filter: function (model, value, options) { | ||
value = (value || (this.model.get('value') || '')); | ||
setElement: function (el, delegate) { | ||
var $el = (el instanceof Backbone.$) ? el : Backbone.$(element); | ||
// Assign accessibility attributes | ||
$el.attr({ | ||
'role': 'combobox', | ||
'aria-owns': this.model.cid, | ||
'aria-autocomplete': 'list' | ||
}); | ||
return Backbone.View.prototype.setElement.call(this, $el, delegate); | ||
}, | ||
/** | ||
* Filter out matching items | ||
*/ | ||
filter: function () { | ||
var value = (this.model.get('value') || ''); | ||
if (value.length < config.threshold) { | ||
@@ -131,2 +155,12 @@ return []; | ||
whenCandidate: function (model, value, options) { | ||
if (value) { | ||
// Remove candidate state from all other items | ||
_(this.collection.without(model)).invoke('set', NOT_CANDIDATE); | ||
// Set ARIA selected decendant | ||
this.$el.attr('aria-activedescendant', model.cid); | ||
} | ||
}, | ||
/** | ||
@@ -136,5 +170,17 @@ * Reflect model changes in the DOM | ||
onSelect: function (model, value, options) { | ||
var notState; | ||
if (value) { | ||
this.$el.val(model.get(config.filterAttr)); | ||
_(this.collection.without(model)).invoke('set', 'isSelected', false); | ||
// Save ref. to selected for ease of access | ||
this.model.set(SELECTED, model.id); | ||
// Ensure that selected model also is candidate (for future ref.) | ||
model.set(IS_CANDIDATE); | ||
// Output selection in input | ||
this.$el.val(model.get(config.filterAttr)).select(); | ||
// Remove candidate and selected states from all other models | ||
notState = _.extend({}, NOT_CANDIDATE, NOT_SELECTED); | ||
_(this.collection.without(model)).invoke('set', notState); | ||
} | ||
@@ -144,5 +190,12 @@ }, | ||
onKeydown: function (event) { | ||
var candidate; | ||
var collection = this.collection; | ||
var key = KEY_MAP[event.keyCode]; | ||
if (key === 'enter') { | ||
candidate = collection.findWhere(IS_CANDIDATE); | ||
// Select candidate | ||
(candidate || this.filter()[0]).set(IS_SELECTED); | ||
// Hide list and prevent form from posting | ||
@@ -158,4 +211,5 @@ this.render(); | ||
onKeyup: function (event) { | ||
var selected, index, filter, matches, next; | ||
var candidate, index, matches, next; | ||
var collection = this.collection; | ||
var selected = collection.get(this.model.get(SELECTED)); | ||
var key = KEY_MAP[event.keyCode]; | ||
@@ -165,2 +219,7 @@ | ||
case 'escape': | ||
// Reset last selected value if user aborts | ||
if (selected) { | ||
this.$el.val(selected.get(config.filterAttr)).select(); | ||
} | ||
// Hide everything on escape key | ||
@@ -170,50 +229,47 @@ this.render(); | ||
case 'enter': | ||
case 'up': | ||
case 'down': | ||
filter = _.partial(this.search, this.model.get('value')); | ||
matches = collection.filter(filter); | ||
selected = collection.findWhere(IS_SELECTED); | ||
matches = this.filter(); | ||
candidate = collection.findWhere(IS_CANDIDATE); | ||
index = _.indexOf(matches, candidate); | ||
if (!selected || !_.contains(matches, selected)) { | ||
// If there's no selected model | ||
switch (key) { | ||
case 'enter': | ||
case 'down': | ||
// Enter and down both select the first one | ||
switch (key) { | ||
case 'down': | ||
if (candidate) { | ||
next = (index === (matches.length - 1)) ? 0 : (index + 1); | ||
} else { | ||
next = 0; | ||
break; | ||
case 'up': | ||
// Up selects the last one | ||
} | ||
break; | ||
case 'up': | ||
if (candidate) { | ||
next = (index === 0) ? (matches.length - 1) : (index - 1); | ||
} else { | ||
next = (matches.length - 1); | ||
} | ||
break; | ||
} | ||
} else { | ||
// Default next to be whatever's selected already | ||
next = index = _.indexOf(matches, selected); | ||
switch (key) { | ||
case 'down': | ||
// Get next one down or first if we're at the bottom | ||
next = (index === (matches.length - 1)) ? 0 : (index + 1); | ||
break; | ||
case 'up': | ||
// Get the previous one or or the last one if we're at the to | ||
next = (index === 0) ? (matches.length - 1) : (index - 1); | ||
break; | ||
} | ||
default: | ||
next = index; | ||
break; | ||
} | ||
// Set state of the now selected model | ||
matches[next].set(IS_SELECTED); | ||
// Set state of the now candidate model | ||
matches[next].set(IS_CANDIDATE); | ||
break; | ||
default: | ||
// All uncaptured and alpha-numeric keys perform filtering | ||
if (key || (event.keyCode >= 48)) { | ||
this.model.set('value', this.$el.val()); | ||
this.collection.invoke('set', 'isSelected', false); | ||
this.render(this.filter()); | ||
// All uncaptured and alpha-numeric keys perform filtering | ||
if ((key && (key !== 'enter')) || (event.keyCode >= 48)) { | ||
this.model.set('value', this.$el.val()); | ||
matches = this.filter(); | ||
if (matches.length && !_.contains(matches, selected)) { | ||
// If the currently selected is not a match | ||
// set first match as candidate | ||
matches[0].set(IS_CANDIDATE); | ||
} | ||
this.render(matches); | ||
} | ||
break; | ||
@@ -235,2 +291,9 @@ } | ||
onBlur: function (event) { | ||
var selected = this.collection.get(this.model.get(SELECTED)); | ||
if (selected) { | ||
// When leaving the input, reset value to selected | ||
this.$el.val(selected.get(config.filterAttr)); | ||
} | ||
this.render(); | ||
@@ -243,2 +306,6 @@ }, | ||
onFocus: function (event) { | ||
// Select text in input for easy editing | ||
Backbone.$(event.target).select(); | ||
// Render propositions | ||
this.render(this.filter()); | ||
@@ -251,24 +318,19 @@ }, | ||
onSync: function (collection, resp, options) { | ||
this.$el.removeClass('is-loading is-invalid'); | ||
var matches = this.filter(); | ||
var selected = this.collection.get(this.model.get(SELECTED)); | ||
this.render(this.filter()); | ||
}, | ||
if (!selected) { | ||
// Unset selected ref. if it's not among the new models | ||
this.model.unset(SELECTED); | ||
/** | ||
* Empty list on error | ||
*/ | ||
onError: function (collection, xhr, options) { | ||
this.$el.addClass('is-invalid'); | ||
// Set first hit as candidate | ||
if (matches.length) { | ||
matches[0].set(IS_CANDIDATE); | ||
} | ||
} | ||
this.render(); | ||
this.render(matches); | ||
}, | ||
/** | ||
* Indicate loading state | ||
*/ | ||
onRequest: function (collection, xhr, options) { | ||
this.$el.addClass('is-loading').removeClass('is-invalid'); | ||
}, | ||
/** | ||
* Renders models handed to it | ||
@@ -278,19 +340,43 @@ */ | ||
var $frag = Backbone.$([]); | ||
var $list = (cache.$list || (cache.$list = Backbone.$(this.template()))); | ||
var attrs = _.clone(this.model.attributes); | ||
cache.$list = (cache.$list || Backbone.$(this.template(attrs))); | ||
if (!models || !models.length) { | ||
// Remove list if there are no models to render | ||
cache.$list.remove(); | ||
// Unset all ARIA selected attributes | ||
this.$el.attr(NOT_EXPANDED).removeAttr('aria-activedescendant'); | ||
return this; | ||
} | ||
// Apply optional limit | ||
if (config.limit) { | ||
models = _.first(models, config.limit); | ||
} | ||
// Create list item views | ||
_(models).forEach(function (model, index, list) { | ||
var view = new config.Item({ | ||
model: model | ||
}); | ||
}).render(); | ||
$frag = $frag.add(view.render().$el); | ||
// Ensure list element id (for ARIA's sake) | ||
view.$el.attr('id', model.cid); | ||
// Append item view el to DOM fragment | ||
$frag = $frag.add(view.$el); | ||
}); | ||
$list.html($frag); | ||
$list.insertAfter(this.$el); | ||
// Show list | ||
cache.$list | ||
.html($frag) | ||
.attr({ | ||
'role': 'listbox', | ||
'id': this.model.cid | ||
}) | ||
.insertAfter(this.$el); | ||
this.$el.attr(IS_EXPANDED); | ||
@@ -317,3 +403,3 @@ return this; | ||
*/ | ||
template: _.template('<ul></ul>'), | ||
template: _.template('<ul />'), | ||
@@ -320,0 +406,0 @@ /** |
@@ -15,2 +15,4 @@ (function (root, factory) { | ||
var NOT_SELECTED = {'isSelected': false}; | ||
var NOT_CANDIDATE = {'isCandidate': false}; | ||
var EVENT_MAP = { | ||
@@ -22,4 +24,2 @@ 'mousedown.autocomplete': 'onClick' | ||
constructor: function (options) { | ||
_.bindAll(this, 'onSelect'); | ||
// Merge events | ||
@@ -29,4 +29,2 @@ options.events = (_.result(options, 'events') || {}); | ||
this.listenTo(options.model, 'change:isSelected', this.onSelect); | ||
return Backbone.View.call(this, options); | ||
@@ -39,10 +37,13 @@ }, | ||
onSelect: function (model, value, options) { | ||
this.$el.toggleClass('is-selected', value); | ||
}, | ||
template: _.template('<li><%= label %></li>'), | ||
template: _.template('<li <% if (data.isSelected) { %>class="is-selected"<% } %>><%= data.label %></li>'), | ||
render: function () { | ||
var attrs = this.model.attributes; | ||
var data = _.defaults({}, attrs, NOT_CANDIDATE, NOT_SELECTED); | ||
render: function () { | ||
return this.setElement(this.template({data: this.model.attributes})); | ||
this.setElement(this.template(data)); | ||
this.$el.attr('id', this.model.cid); | ||
return this; | ||
} | ||
@@ -49,0 +50,0 @@ }); |
{ | ||
"name": "backbone.asyncautocomplete", | ||
"version": "0.1.2", | ||
"version": "1.0.0", | ||
"description": "Autocomplete with async support as Backbone View", | ||
@@ -18,4 +18,2 @@ "main": "main.js", | ||
"async", | ||
"datalist", | ||
"pollyfill", | ||
"autocomplete" | ||
@@ -22,0 +20,0 @@ ], |
@@ -40,3 +40,3 @@ # Backbone.AsyncAutocomplete | ||
var MyItem = AsyncAutocomplete.Item.extend({ | ||
template: _.template('<li class="MyAutocompleteItem" />') | ||
template: _.template('<li class="MyAutocompleteItem"><%= name %></li>') | ||
}); | ||
@@ -59,11 +59,13 @@ | ||
- `Item` The view class to be used for individual autocomplete list items. | ||
- Default: `AsyncAutocomplete.Item`. | ||
- *Default: `AsyncAutocomplete.Item`* | ||
- `wait` How long to wait after user input before performing a fetch. | ||
- *Default*: `250`. | ||
- *Default: `250`* | ||
- `filterAttr` The model attribute which to use for the filtering the collection. For special filtering needs where just one attribute is not enough, see [`search`](#search-method). | ||
- *Default*: `label`. | ||
- `searchAttr` When calling fetch on the collection, this will be query parameter holding the search term like so: `{data: {'SEARCH_ATTR': 'Daytona'}}`. For more advanced need, configure the collection's fetch method. | ||
- *Default*: `search`. | ||
- *Default: `label`* | ||
- `searchAttr` When calling fetch on the collection, this will be the query parameter holding the search term like so: `{data: {'SEARCH_ATTR': 'Daytona'}}`. For more advanced needs, configure your collection's fetch method. | ||
- *Default: `search`* | ||
- `threshold` The minimum number of characters required before performing a fetch call. | ||
- *Default*: `2`. | ||
- *Default: `2`* | ||
- `limit` The maximum number of items allowed to be rendered. Useful when dealing with large data sets. | ||
- *Default: `false`* | ||
@@ -90,8 +92,10 @@ ### Async requirements | ||
## Notes | ||
## Handling states | ||
### Default DOM methods | ||
This script makes an effort to not assume anything about how you might set states or name your CSS classes. Using the item attributes `isSelected` and `isCandidate` in the template will get you part of the way but you will also have to append classes and whatnot to the items as these attributes change. Have a look at the [example](example/demo.js) to see how it can be done. | ||
This script makes an effort not to assume anything about how you might set states or name your CSS classes. There are, however, some occations upon which the script need to change markup in the DOM without calling the `template` methods. These occations are when the collection is fetching models, on fetch error and on model selection (using enter and arrow keys). | ||
### `isSelected` | ||
Only one model at a time may be "selected". A model becomes selected whenever the user clicks on it or navigates to it (using their arrow keys) and then hit the `enter` key. | ||
The classes follow the [SUIT syntax](http://suitcss.github.io). If these classes are not to your liking, overwrite the Autocomplete's `onSync`, `onError` and `onRequest` methods. As well as the Item's `onSelect` method. | ||
### `isCandidate` | ||
A candidate item is pretty much the same as hovering an item. Whenever an item is navigated to using the arrow keys, it becomes a "candidate". There can be only one candidate in a collection. |
@@ -44,3 +44,7 @@ (function (root, factory) { | ||
"name": "Clementina DuBuque" | ||
}]; | ||
}].map(function (model, index, list) { | ||
// The default item template expects a label | ||
model.label = model.name; | ||
return model; | ||
}); | ||
@@ -68,3 +72,3 @@ Backbone.$ = (Backbone.$ || $); | ||
teardown: function () { | ||
$('#input').remove(); | ||
$('#input').trigger('blur').remove(); | ||
} | ||
@@ -97,3 +101,2 @@ }); | ||
$el.val('test').trigger('change'); | ||
assert.strictEqual($el.val(), view.model.get('value'), 'Changes with DOM'); | ||
@@ -103,8 +106,19 @@ }); | ||
QUnit.test('Custom settings', function (assert) { | ||
var count; | ||
var $el = $('#input'); | ||
var CustomView = AsyncAutocomplete.define({ | ||
filterAttr: 'name', | ||
threshold: 4 | ||
threshold: 3, | ||
limit: 1 | ||
}); | ||
var superRender = CustomView.prototype.render; | ||
CustomView = CustomView.extend({ | ||
render: function (models, cache) { | ||
superRender.call(this, models, cache); | ||
count = cache.$list.children().length; | ||
return this; | ||
} | ||
}); | ||
var view = new CustomView({ | ||
@@ -115,9 +129,10 @@ el: $el, | ||
$el.val('lea').trigger('change'); | ||
$el.val('le').trigger('change'); | ||
assert.strictEqual(view.filter().length, 0, 'Threshold prevents filtering'); | ||
$el.val('lean').trigger('change'); | ||
$el.val('lea').trigger('change'); | ||
assert.ok((view.filter().length > 0), 'Filters by custom attribute'); | ||
assert.ok((view.filter().length > 0), 'Filters by custom attribute'); | ||
$el.val('enn').trigger('change'); | ||
assert.strictEqual(count, 1, 'Limits number of rendered results'); | ||
}); | ||
@@ -137,7 +152,5 @@ | ||
$el.val('na').trigger('change'); | ||
assert.strictEqual(view.filter().length, 3, '"na" gives three hits'); | ||
$el.val('foo').trigger('change'); | ||
assert.strictEqual(view.filter().length, 0, '"foo" gives no hits'); | ||
@@ -144,0 +157,0 @@ }); |
Sorry, the diff of this file is not supported yet
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
35715
898
0
99