58598d14 by Michael Richards

Merge pull request #48 from mikeric/iteration-binding

Iteration binding
2 parents fb1984a9 9a000b97
......@@ -104,6 +104,7 @@ Available bindings out-of-the-box:
- data-unchecked
- data-*[attribute]*
- data-on-*[event]*
- data-each-*[item]*
#### Formatters
......@@ -143,9 +144,34 @@ Computed properties are functions that get re-evaluated when one or more depende
#### Iteration Binding
Even though a binding routine for `each-item` will likely be included in Rivets.js, you can use the `data-html` binding along with a set of formatters in the interim to do sorting and iterative rendering of collections (amongst other cool things).
Use the `data-each-[item]` binding to have Rivets.js automatically loop over items in an array and append bound instances of that element. Within that element you can bind to the iterated item as well as any contexts that are available in the parent view.
<ul data-html="model.tags | sort | tagList"></ul>
```
<ul>
<li data-each-todo="list.todos">
<input type="checkbox" data-checked="todo.done">
<span data-text="todo.summary"></span>
</li>
<ul>
```
If the array you're binding to contains non-model objects (they don't conform to your adapter), you can still iterate over them, just make sure to use the adapter bypass syntax — in doing so, the iteration binding will still update when the array changes, however the individual items will not since they'd be bypassing the `adapter.subscribe`.
```
<ul>
<li data-each-link="item.links">
<a data-href="link:url" data-text="link:title"></span>
</li>
</ul>
```
Also note that you may bind to the iterated item directly on the parent element.
```
<ul>
<li data-each-tag="item.tags" data-text="tag:name"></li>
</ul>
```
## Building and Testing
......
......@@ -116,6 +116,19 @@ describe('Rivets.Binding', function() {
expect(binding.routine).toHaveBeenCalledWith(el, funcb, funca);
});
});
describe('on an iteration binding', function(){
beforeEach(function(){
binding.options.special = 'iteration';
});
it('performs the binding routine with the supplied collection and binding', function() {
spyOn(binding, 'routine');
array = [{name: 'a'}, {name: 'b'}];
binding.set(array);
expect(binding.routine).toHaveBeenCalledWith(el, array, binding);
});
});
});
describe('publish()', function() {
......
......@@ -2,7 +2,7 @@ describe('Functional', function() {
var data, bindData, el, input;
beforeEach(function() {
data = new Data({foo: 'bar'});
data = new Data({foo: 'bar', items: [{name: 'a'}, {name: 'b'}]});
bindData = {data: data};
el = document.createElement('div');
input = document.createElement('input');
......@@ -100,6 +100,52 @@ describe('Functional', function() {
expect(input.value).toBe(data.get('foo'));
});
});
describe('Iteration', function() {
beforeEach(function(){
list = document.createElement('ul');
el.appendChild(list);
listItem = document.createElement('li');
listItem.setAttribute('data-each-item', 'data.items');
list.appendChild(listItem);
});
it('should loop over a collection and create new instances of that element + children', function() {
expect(el.getElementsByTagName('li').length).toBe(1);
rivets.bind(el, bindData);
expect(el.getElementsByTagName('li').length).toBe(2);
});
it('should re-loop over the collection and create new instances when the array changes', function() {
rivets.bind(el, bindData);
expect(el.getElementsByTagName('li').length).toBe(2);
newItems = [{name: 'a'}, {name: 'b'}, {name: 'c'}];
data.set({items: newItems});
expect(el.getElementsByTagName('li').length).toBe(3);
});
it('should allow binding to the iterated item as well as any parent contexts', function() {
span1 = document.createElement('span');
span1.setAttribute('data-text', 'item:name')
span2 = document.createElement('span');
span2.setAttribute('data-text', 'data.foo')
listItem.appendChild(span1);
listItem.appendChild(span2);
rivets.bind(el, bindData);
expect(el.getElementsByTagName('span')[0]).toHaveTheTextContent('a');
expect(el.getElementsByTagName('span')[1]).toHaveTheTextContent('bar');
});
it('should allow binding to the iterated element directly', function() {
listItem.setAttribute('data-text', 'item:name');
listItem.setAttribute('data-class', 'data.foo');
rivets.bind(el, bindData);
expect(el.getElementsByTagName('li')[0]).toHaveTheTextContent('a');
expect(el.getElementsByTagName('li')[0].className).toBe('bar');
});
});
});
describe('Updates', function() {
......
......@@ -15,10 +15,10 @@ class Rivets.Binding
# element, the type of binding, the model object and the keypath at which
# to listen for changes.
constructor: (@el, @type, @model, @keypath, @options = {}) ->
if @options.special is "event"
@routine = eventBinding @type
else
@routine = Rivets.routines[@type] || attributeBinding @type
@routine = switch @options.special
when "event" then eventBinding @type
when "iteration" then iterationBinding @type
else Rivets.routines[@type] || attributeBinding @type
@formatters = @options.formatters || []
......@@ -47,6 +47,8 @@ class Rivets.Binding
if @options.special is "event"
@routine @el, value, @currentListener
@currentListener = value
else if @options.special is "iteration"
@routine @el, value, @
else
value = value.call(@model) if value instanceof Function
@routine @el, value
......@@ -85,15 +87,16 @@ class Rivets.Binding
# Unsubscribes from the model and the element.
unbind: =>
Rivets.config.adapter.unsubscribe @model, @keypath, @set
unless @options.bypass
Rivets.config.adapter.unsubscribe @model, @keypath, @set
if @options.dependencies?.length
for keypath in @options.dependencies
callback = @dependencyCallbacks[keypath]
Rivets.config.adapter.unsubscribe @model, keypath, callback
if @options.dependencies?.length
for keypath in @options.dependencies
callback = @dependencyCallbacks[keypath]
Rivets.config.adapter.unsubscribe @model, keypath, callback
if @type in @bidirectionals
@el.removeEventListener 'change', @publish
if @type in @bidirectionals
@el.removeEventListener 'change', @publish
# A collection of bindings built from a set of parent elements.
class Rivets.View
......@@ -111,32 +114,57 @@ class Rivets.View
# Builds the Rivets.Binding instances for the view.
build: =>
@bindings = []
skipNodes = []
iterator = null
bindingRegExp = @bindingRegExp()
eventRegExp = /^on-/
iterationRegExp = /^each-/
parseNode = (node) =>
for attribute in node.attributes
if bindingRegExp.test attribute.name
options = {}
type = attribute.name.replace bindingRegExp, ''
pipes = (pipe.trim() for pipe in attribute.value.split '|')
context = (ctx.trim() for ctx in pipes.shift().split '>')
path = context.shift()
splitPath = path.split /\.|:/
options.formatters = pipes
model = @models[splitPath.shift()]
options.bypass = path.indexOf(":") != -1
keypath = splitPath.join()
if dependencies = context.shift()
options.dependencies = dependencies.split /\s+/
if eventRegExp.test type
type = type.replace eventRegExp, ''
options.special = "event"
@bindings.push new Rivets.Binding node, type, model, keypath, options
unless node in skipNodes
for attribute in node.attributes
if bindingRegExp.test attribute.name
type = attribute.name.replace bindingRegExp, ''
if iterationRegExp.test type
unless @models[type.replace iterationRegExp, '']
skipNodes.push n for n in node.getElementsByTagName '*'
iterator = [attribute]
for attribute in iterator or node.attributes
if bindingRegExp.test attribute.name
options = {}
type = attribute.name.replace bindingRegExp, ''
pipes = (pipe.trim() for pipe in attribute.value.split '|')
context = (ctx.trim() for ctx in pipes.shift().split '>')
path = context.shift()
splitPath = path.split /\.|:/
options.formatters = pipes
model = @models[splitPath.shift()]
options.bypass = path.indexOf(":") != -1
keypath = splitPath.join()
if model
if dependencies = context.shift()
options.dependencies = dependencies.split /\s+/
if eventRegExp.test type
type = type.replace eventRegExp, ''
options.special = "event"
if iterationRegExp.test type
type = type.replace iterationRegExp, ''
options.special = "iteration"
binding = new Rivets.Binding node, type, model, keypath, options
binding.view = @
@bindings.push binding
if iterator
node.removeAttribute(a.name) for a in iterator
iterator = null
for el in @els
parseNode el
......@@ -180,6 +208,31 @@ eventBinding = (event) -> (el, bind, unbind) ->
bindEvent el, event, bind if bind
unbindEvent el, event, unbind if unbind
# Returns an iteration binding routine for the specified collection.
iterationBinding = (name) -> (el, collection, binding) ->
if binding.iterated?
for iteration in binding.iterated
iteration.view.unbind()
iteration.el.parentNode.removeChild iteration.el
else
binding.marker = document.createComment " rivets: each-#{name} "
el.parentNode.insertBefore binding.marker, el
el.parentNode.removeChild el
binding.iterated = []
for item in collection
data = {}
data[n] = m for n, m of binding.view.models
data[name] = item
itemEl = el.cloneNode true
previous = binding.iterated[binding.iterated.length - 1] or binding.marker
binding.marker.parentNode.insertBefore itemEl, previous.nextSibling
binding.iterated.push
el: itemEl
view: rivets.bind itemEl, data
# Returns an attribute binding routine for the specified attribute. This is what
# is used when there are no matching routines for an identifier.
attributeBinding = (attr) -> (el, value) ->
......