New Case Study:See how Anthropic automated 95% of dependency reviews with Socket.Learn More
Socket
Sign inDemoInstall
Socket

backbone.asyncautocomplete

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

backbone.asyncautocomplete - npm Package Compare versions

Comparing version 0.1.2 to 1.0.0

7

bower.json
{
"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

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc