cfa4fce3 by Michael Richards

Merge pull request #209 from mikeric/adapters

0.6.0 WIP
2 parents dacf10b9 217e4e54
_SpecRunner.html
.DS_Store
*.swp
.grunt/**/*
node_modules/**/*
......
......@@ -11,8 +11,20 @@ module.exports = (grunt) ->
coffee:
all:
options:
join: true
files:
'dist/rivets.js': 'src/rivets.coffee'
'dist/rivets.js': [
'src/rivets.coffee'
'src/util.coffee'
'src/view.coffee'
'src/bindings.coffee'
'src/parsers.coffee'
'src/keypath_observer.coffee'
'src/binders.coffee'
'src/adapters.coffee'
'src/export.coffee'
]
concat:
all:
......
......@@ -2,19 +2,13 @@ describe('Rivets.Binding', function() {
var model, el, view, binding, opts;
beforeEach(function() {
rivets.configure({
adapter: {
subscribe: function() {},
unsubscribe: function() {},
read: function() {},
publish: function() {}
}
});
rivets.config.prefix = 'data'
adapter = rivets.adapters['.']
el = document.createElement('div');
el.setAttribute('data-text', 'obj.name');
opts = {};
view = rivets.bind(el, {obj: {}}, opts);
view = rivets.bind(el, {obj: {}});
binding = view.bindings[0];
model = binding.model;
});
......@@ -25,9 +19,9 @@ describe('Rivets.Binding', function() {
describe('bind()', function() {
it('subscribes to the model for changes via the adapter', function() {
spyOn(rivets.config.adapter, 'subscribe');
spyOn(adapter, 'subscribe');
binding.bind();
expect(rivets.config.adapter.subscribe).toHaveBeenCalledWith(model, 'name', binding.sync);
expect(adapter.subscribe).toHaveBeenCalledWith(model, 'name', binding.sync);
});
it("calls the binder's bind method if one exists", function() {
......@@ -48,47 +42,23 @@ describe('Rivets.Binding', function() {
it('sets the initial value via the adapter', function() {
spyOn(binding, 'set');
spyOn(rivets.config.adapter, 'read');
spyOn(adapter, 'read');
binding.bind();
expect(rivets.config.adapter.read).toHaveBeenCalledWith(model, 'name');
expect(adapter.read).toHaveBeenCalledWith(model, 'name');
expect(binding.set).toHaveBeenCalled();
});
});
describe('with the bypass option set to true', function() {
beforeEach(function() {
binding.options.bypass = true;
});
it('sets the initial value from the model directly', function() {
spyOn(binding, 'set');
binding.model.name = 'espresso';
binding.bind();
expect(binding.set).toHaveBeenCalledWith('espresso');
});
it("calls the binder's bind method if one exists", function() {
expect(function(){
binding.bind();
}).not.toThrow(new Error());
binding.binder.bind = function(){};
spyOn(binding.binder, 'bind');
binding.bind();
expect(binding.binder.bind).toHaveBeenCalled();
});
});
describe('with dependencies', function() {
beforeEach(function() {
binding.options.dependencies = ['.fname', '.lname'];
});
it('sets up observers on the dependant attributes', function() {
spyOn(rivets.config.adapter, 'subscribe');
spyOn(adapter, 'subscribe');
binding.bind();
expect(rivets.config.adapter.subscribe).toHaveBeenCalledWith(model, 'fname', binding.sync);
expect(rivets.config.adapter.subscribe).toHaveBeenCalledWith(model, 'lname', binding.sync);
expect(adapter.subscribe).toHaveBeenCalledWith(model, 'fname', binding.sync);
expect(adapter.subscribe).toHaveBeenCalledWith(model, 'lname', binding.sync);
});
});
});
......@@ -104,23 +74,6 @@ describe('Rivets.Binding', function() {
binding.unbind();
expect(binding.binder.unbind).toHaveBeenCalled();
});
describe('with the bypass option set to true', function() {
beforeEach(function() {
binding.options.bypass = true;
});
it("calls the binder's unbind method if one exists", function() {
expect(function(){
binding.unbind();
}).not.toThrow(new Error());
binding.binder.unbind = function(){};
spyOn(binding.binder, 'unbind');
binding.unbind();
expect(binding.binder.unbind).toHaveBeenCalled();
});
});
});
describe('set()', function() {
......@@ -158,9 +111,9 @@ describe('Rivets.Binding', function() {
numberInput.value = 42;
spyOn(rivets.config.adapter, 'publish');
spyOn(adapter, 'publish');
binding.publish({target: numberInput});
expect(rivets.config.adapter.publish).toHaveBeenCalledWith(model, 'num', '42');
expect(adapter.publish).toHaveBeenCalledWith(model, 'num', '42');
});
});
......@@ -191,9 +144,9 @@ describe('Rivets.Binding', function() {
numberInput.value = 42;
spyOn(rivets.config.adapter, 'publish');
spyOn(adapter, 'publish');
binding.publish({target: numberInput});
expect(rivets.config.adapter.publish).toHaveBeenCalledWith(model, 'num', 'awesome 42');
expect(adapter.publish).toHaveBeenCalledWith(model, 'num', 'awesome 42');
});
it("should format a value in both directions", function() {
......@@ -209,10 +162,10 @@ describe('Rivets.Binding', function() {
binding = view.bindings[0];
model = binding.model;
spyOn(rivets.config.adapter, 'publish');
spyOn(adapter, 'publish');
valueInput.value = 'charles';
binding.publish({target: valueInput});
expect(rivets.config.adapter.publish).toHaveBeenCalledWith(model, 'name', 'awesome charles');
expect(adapter.publish).toHaveBeenCalledWith(model, 'name', 'awesome charles');
spyOn(binding.binder, 'routine');
binding.set('fred');
......@@ -229,10 +182,10 @@ describe('Rivets.Binding', function() {
binding = view.bindings[0];
model = binding.model;
spyOn(rivets.config.adapter, 'publish');
spyOn(adapter, 'publish');
valueInput.value = 'charles';
binding.publish({target: valueInput});
expect(rivets.config.adapter.publish).toHaveBeenCalledWith(model, 'name', 'charles');
expect(adapter.publish).toHaveBeenCalledWith(model, 'name', 'charles');
spyOn(binding.binder, 'routine');
binding.set('fred');
......@@ -262,10 +215,10 @@ describe('Rivets.Binding', function() {
binding.set('fred');
expect(binding.binder.routine).toHaveBeenCalledWith(valueInput, 'fred is awesome totally');
spyOn(rivets.config.adapter, 'publish');
spyOn(adapter, 'publish');
valueInput.value = 'fred';
binding.publish({target: valueInput});
expect(rivets.config.adapter.publish).toHaveBeenCalledWith(model, 'name', 'fred totally is awesome');
expect(adapter.publish).toHaveBeenCalledWith(model, 'name', 'fred totally is awesome');
});
it("binders in a chain should be skipped if they're not there", function() {
......@@ -290,10 +243,10 @@ describe('Rivets.Binding', function() {
binding.set('fred');
expect(binding.binder.routine).toHaveBeenCalledWith(valueInput, 'fred is awesome totally');
spyOn(rivets.config.adapter, 'publish');
spyOn(adapter, 'publish');
valueInput.value = 'fred';
binding.publish({target: valueInput});
expect(rivets.config.adapter.publish).toHaveBeenCalledWith(model, 'name', 'fred totally is radical');
expect(adapter.publish).toHaveBeenCalledWith(model, 'name', 'fred totally is radical');
});
});
......@@ -305,12 +258,6 @@ describe('Rivets.Binding', function() {
expect(binding.formattedValue('hat')).toBe('awesome hat');
});
it('uses formatters on the model', function() {
model.modelAwesome = function(value) { return 'model awesome ' + value; };
binding.formatters.push('modelAwesome');
expect(binding.formattedValue('hat')).toBe('model awesome hat');
});
describe('with a multi-argument formatter string', function() {
beforeEach(function() {
view.formatters.awesome = function(value, prefix) {
......
......@@ -2,18 +2,13 @@ describe('Functional', function() {
var data, bindData, el, input;
beforeEach(function() {
data = new Data({foo: 'bar', items: [{name: 'a'}, {name: 'b'}]});
bindData = {data: data};
el = document.createElement('div');
input = document.createElement('input');
input.setAttribute('type', 'text');
rivets.configure({
preloadData: true,
adapter: {
adapter = {
subscribe: function(obj, keypath, callback) {
obj.on(keypath, callback);
},
unsubscribe: function(obj, keypath, callback) {
obj.off(keypath, callback);
},
read: function(obj, keypath) {
return obj.get(keypath);
},
......@@ -22,14 +17,27 @@ describe('Functional', function() {
attributes[keypath] = value;
obj.set(attributes);
}
}
};
rivets.adapters[':'] = adapter;
rivets.configure({preloadData: true});
data = new Data({
foo: 'bar',
items: [{name: 'a'}, {name: 'b'}]
});
bindData = {data: data};
el = document.createElement('div');
input = document.createElement('input');
input.setAttribute('type', 'text');
});
describe('Adapter', function() {
it('should read the initial value', function() {
spyOn(data, 'get');
el.setAttribute('data-text', 'data.foo');
el.setAttribute('data-text', 'data:foo');
rivets.bind(el, bindData);
expect(data.get).toHaveBeenCalledWith('foo');
});
......@@ -37,14 +45,14 @@ describe('Functional', function() {
it('should read the initial value unless preloadData is false', function() {
rivets.configure({preloadData: false});
spyOn(data, 'get');
el.setAttribute('data-value', 'data.foo');
el.setAttribute('data-value', 'data:foo');
rivets.bind(el, bindData);
expect(data.get).not.toHaveBeenCalled();
});
it('should subscribe to updates', function() {
spyOn(data, 'on');
el.setAttribute('data-value', 'data.foo');
el.setAttribute('data-value', 'data:foo');
rivets.bind(el, bindData);
expect(data.on).toHaveBeenCalled();
});
......@@ -53,13 +61,14 @@ describe('Functional', function() {
describe('Binds', function() {
describe('Text', function() {
it('should set the text content of the element', function() {
el.setAttribute('data-text', 'data.foo');
el.setAttribute('data-text', 'data:foo');
rivets.bind(el, bindData);
debugger
expect(el.textContent || el.innerText).toBe(data.get('foo'));
});
it('should correctly handle HTML in the content', function() {
el.setAttribute('data-text', 'data.foo');
el.setAttribute('data-text', 'data:foo');
value = '<b>Fail</b>';
data.set({foo: value});
rivets.bind(el, bindData);
......@@ -69,13 +78,13 @@ describe('Functional', function() {
describe('HTML', function() {
it('should set the html content of the element', function() {
el.setAttribute('data-html', 'data.foo');
el.setAttribute('data-html', 'data:foo');
rivets.bind(el, bindData);
expect(el).toHaveTheTextContent(data.get('foo'));
});
it('should correctly handle HTML in the content', function() {
el.setAttribute('data-html', 'data.foo');
el.setAttribute('data-html', 'data:foo');
value = '<b>Fail</b>';
data.set({foo: value});
rivets.bind(el, bindData);
......@@ -85,7 +94,7 @@ describe('Functional', function() {
describe('Value', function() {
it('should set the value of the element', function() {
input.setAttribute('data-value', 'data.foo');
input.setAttribute('data-value', 'data:foo');
rivets.bind(input, bindData);
expect(input.value).toBe(data.get('foo'));
});
......@@ -93,8 +102,8 @@ describe('Functional', function() {
describe('Multiple', function() {
it('should bind a list of multiple elements', function() {
el.setAttribute('data-html', 'data.foo');
input.setAttribute('data-value', 'data.foo');
el.setAttribute('data-html', 'data:foo');
input.setAttribute('data-value', 'data:foo');
rivets.bind([el, input], bindData);
expect(el).toHaveTheTextContent(data.get('foo'));
expect(input.value).toBe(data.get('foo'));
......@@ -106,7 +115,7 @@ describe('Functional', function() {
list = document.createElement('ul');
el.appendChild(list);
listItem = document.createElement('li');
listItem.setAttribute('data-each-item', 'data.items');
listItem.setAttribute('data-each-item', 'data:items');
list.appendChild(listItem);
});
......@@ -133,9 +142,9 @@ describe('Functional', function() {
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')
span1.setAttribute('data-text', 'item.name')
span2 = document.createElement('span');
span2.setAttribute('data-text', 'data.foo')
span2.setAttribute('data-text', 'data:foo')
listItem.appendChild(span1);
listItem.appendChild(span2);
......@@ -145,8 +154,8 @@ describe('Functional', function() {
});
it('should allow binding to the iterated element directly', function() {
listItem.setAttribute('data-text', 'item:name');
listItem.setAttribute('data-class', 'data.foo');
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');
......@@ -160,7 +169,7 @@ describe('Functional', function() {
list.appendChild(lastItem);
list.insertBefore(firstItem, listItem);
listItem.setAttribute('data-text', 'item:name');
listItem.setAttribute('data-text', 'item.name');
rivets.bind(el, bindData);
expect(el.getElementsByTagName('li')[0]).toHaveTheTextContent('first');
......@@ -174,7 +183,7 @@ describe('Functional', function() {
describe('Updates', function() {
it('should change the value', function() {
el.setAttribute('data-text', 'data.foo');
el.setAttribute('data-text', 'data:foo');
rivets.bind(el, bindData);
data.set({foo: 'some new value'});
expect(el).toHaveTheTextContent(data.get('foo'));
......@@ -183,7 +192,7 @@ describe('Functional', function() {
describe('Input', function() {
it('should update the model value', function() {
input.setAttribute('data-value', 'data.foo');
input.setAttribute('data-value', 'data:foo');
rivets.bind(input, bindData);
input.value = 'some new value';
var event = document.createEvent('HTMLEvents')
......
# The default `.` adapter thats comes with Rivets.js. Allows subscribing to
# properties on POJSOs, implemented in ES5 natives using
# `Object.defineProperty`.
Rivets.adapters['.'] =
id: '_rv'
counter: 0
weakmap: {}
weakReference: (obj) ->
unless obj[@id]?
id = @counter++
@weakmap[id] =
callbacks: {}
Object.defineProperty obj, @id, value: id
@weakmap[obj[@id]]
stubFunction: (obj, fn) ->
original = obj[fn]
map = @weakReference obj
weakmap = @weakmap
obj[fn] = ->
response = original.apply obj, arguments
for r, k of map.pointers
callback() for callback in weakmap[r]?.callbacks[k] ? []
response
observeMutations: (obj, ref, keypath) ->
if Array.isArray obj
map = @weakReference obj
unless map.pointers?
map.pointers = {}
functions = ['push', 'pop', 'shift', 'unshift', 'sort', 'reverse', 'splice']
@stubFunction obj, fn for fn in functions
map.pointers[ref] ?= []
unless keypath in map.pointers[ref]
map.pointers[ref].push keypath
unobserveMutations: (obj, ref, keypath) ->
if Array.isArray obj and obj[@id]?
if keypaths = @weakReference(obj).pointers?[ref]
keypaths.splice keypaths.indexOf(keypath), 1
subscribe: (obj, keypath, callback) ->
callbacks = @weakReference(obj).callbacks
unless callbacks[keypath]?
callbacks[keypath] = []
value = obj[keypath]
Object.defineProperty obj, keypath,
get: -> value
set: (newValue) =>
if newValue isnt value
value = newValue
callback() for callback in callbacks[keypath]
@observeMutations newValue, obj[@id], keypath
unless callback in callbacks[keypath]
callbacks[keypath].push callback
@observeMutations obj[keypath], obj[@id], keypath
unsubscribe: (obj, keypath, callback) ->
callbacks = @weakmap[obj[@id]].callbacks[keypath]
callbacks.splice callbacks.indexOf(callback), 1
@unobserveMutations obj[keypath], obj[@id], keypath
read: (obj, keypath) ->
obj[keypath]
publish: (obj, keypath, value) ->
obj[keypath] = value
# Core binders that are included with Rivets.js.
Rivets.binders.enabled = (el, value) ->
el.disabled = !value
Rivets.binders.disabled = (el, value) ->
el.disabled = !!value
Rivets.binders.checked =
publishes: true
bind: (el) ->
Rivets.Util.bindEvent el, 'change', @publish
unbind: (el) ->
Rivets.Util.unbindEvent el, 'change', @publish
routine: (el, value) ->
if el.type is 'radio'
el.checked = el.value?.toString() is value?.toString()
else
el.checked = !!value
Rivets.binders.unchecked =
publishes: true
bind: (el) ->
Rivets.Util.bindEvent el, 'change', @publish
unbind: (el) ->
Rivets.Util.unbindEvent el, 'change', @publish
routine: (el, value) ->
if el.type is 'radio'
el.checked = el.value?.toString() isnt value?.toString()
else
el.checked = !value
Rivets.binders.show = (el, value) ->
el.style.display = if value then '' else 'none'
Rivets.binders.hide = (el, value) ->
el.style.display = if value then 'none' else ''
Rivets.binders.html = (el, value) ->
el.innerHTML = if value? then value else ''
Rivets.binders.value =
publishes: true
bind: (el) ->
Rivets.Util.bindEvent el, 'change', @publish
unbind: (el) ->
Rivets.Util.unbindEvent el, 'change', @publish
routine: (el, value) ->
if window.jQuery?
el = jQuery el
if value?.toString() isnt el.val()?.toString()
el.val if value? then value else ''
else
if el.type is 'select-multiple'
o.selected = o.value in value for o in el if value?
else if value?.toString() isnt el.value?.toString()
el.value = if value? then value else ''
Rivets.binders.text = (el, value) ->
if el.innerText?
el.innerText = if value? then value else ''
else
el.textContent = if value? then value else ''
Rivets.binders.if =
block: true
bind: (el) ->
unless @marker?
attr = [@view.config.prefix, @type].join('-').replace '--', '-'
declaration = el.getAttribute attr
@marker = document.createComment " rivets: #{@type} #{declaration} "
el.removeAttribute attr
el.parentNode.insertBefore @marker, el
el.parentNode.removeChild el
unbind: ->
@nested?.unbind()
routine: (el, value) ->
if !!value is not @nested?
if value
models = {}
models[key] = model for key, model of @view.models
options =
binders: @view.options.binders
formatters: @view.options.formatters
adapters: @view.options.adapters
config: @view.options.config
(@nested = new Rivets.View(el, models, options)).bind()
@marker.parentNode.insertBefore el, @marker.nextSibling
else
el.parentNode.removeChild el
@nested.unbind()
delete @nested
update: (models) ->
@nested?.update models
Rivets.binders.unless =
block: true
bind: (el) ->
Rivets.binders.if.bind.call @, el
unbind: ->
Rivets.binders.if.unbind.call @
routine: (el, value) ->
Rivets.binders.if.routine.call @, el, not value
update: (models) ->
Rivets.binders.if.update.call @, models
Rivets.binders['on-*'] =
function: true
unbind: (el) ->
Rivets.Util.unbindEvent el, @args[0], @handler if @handler
routine: (el, value) ->
Rivets.Util.unbindEvent el, @args[0], @handler if @handler
Rivets.Util.bindEvent el, @args[0], @handler = @eventHandler value
Rivets.binders['each-*'] =
block: true
bind: (el) ->
unless @marker?
attr = [@view.config.prefix, @type].join('-').replace '--', '-'
@marker = document.createComment " rivets: #{@type} "
@iterated = []
el.removeAttribute attr
el.parentNode.insertBefore @marker, el
el.parentNode.removeChild el
unbind: (el) ->
view.unbind() for view in @iterated if @iterated?
routine: (el, collection) ->
modelName = @args[0]
collection = collection or []
if @iterated.length > collection.length
for i in Array @iterated.length - collection.length
view = @iterated.pop()
view.unbind()
@marker.parentNode.removeChild view.els[0]
for model, index in collection
data = {}
data[modelName] = model
if not @iterated[index]?
for key, model of @view.models
data[key] ?= model
previous = if @iterated.length
@iterated[@iterated.length - 1].els[0]
else
@marker
options =
binders: @view.options.binders
formatters: @view.options.formatters
adapters: @view.options.adapters
config: {}
options.config[k] = v for k, v of @view.options.config
options.config.preloadData = true
template = el.cloneNode true
view = new Rivets.View(template, data, options)
view.bind()
@iterated.push view
@marker.parentNode.insertBefore template, previous.nextSibling
else if @iterated[index].models[modelName] isnt model
@iterated[index].update data
if el.nodeName is 'OPTION'
for binding in @view.bindings
if binding.el is @marker.parentNode and binding.type is 'value'
binding.sync()
update: (models) ->
data = {}
for key, model of models
data[key] = model unless key is @args[0]
view.update data for view in @iterated
Rivets.binders['class-*'] = (el, value) ->
elClass = " #{el.className} "
if !value is (elClass.indexOf(" #{@args[0]} ") isnt -1)
el.className = if value
"#{el.className} #{@args[0]}"
else
elClass.replace(" #{@args[0]} ", ' ').trim()
Rivets.binders['*'] = (el, value) ->
if value
el.setAttribute @type, value
else
el.removeAttribute @type
# Rivets.Binding
# --------------
# A single binding between a model attribute and a DOM element.
class Rivets.Binding
# All information about the binding is passed into the constructor; the
# containing view, the DOM node, the type of binding, the model object and the
# keypath at which to listen for changes.
constructor: (@view, @el, @type, @keypath, @options = {}) ->
@formatters = @options.formatters || []
@dependencies = []
@setBinder()
@setObserver()
setBinder: =>
unless @binder = @view.binders[@type]
for identifier, value of @view.binders
if identifier isnt '*' and identifier.indexOf('*') isnt -1
regexp = new RegExp "^#{identifier.replace('*', '.+')}$"
if regexp.test @type
@binder = value
@args = new RegExp("^#{identifier.replace('*', '(.+)')}$").exec @type
@args.shift()
@binder or= @view.binders['*']
@binder = {routine: @binder} if @binder instanceof Function
setObserver: =>
@observer = new KeypathObserver @view, @view.models, @keypath, (obs) =>
@unbind true if @key
@model = obs.target
@bind true if @key
@sync()
@key = @observer.key
@model = @observer.target
# Applies all the current formatters to the supplied value and returns the
# formatted value.
formattedValue: (value) =>
for formatter in @formatters
args = formatter.split /\s+/
id = args.shift()
formatter = @view.formatters[id]
if formatter?.read instanceof Function
value = formatter.read value, args...
else if formatter instanceof Function
value = formatter value, args...
value
# Returns an event handler for the binding around the supplied function.
eventHandler: (fn) =>
handler = (binding = @).view.config.handler
(ev) -> handler.call fn, @, ev, binding
# Sets the value for the binding. This Basically just runs the binding routine
# with the suplied value formatted.
set: (value) =>
value = if value instanceof Function and !@binder.function
@formattedValue value.call @model
else
@formattedValue value
@binder.routine?.call @, @el, value
# Syncs up the view binding with the model.
sync: =>
@set if @key
@view.adapters[@key.interface].read @model, @key.path
else
@model
# Publishes the value currently set on the input element back to the model.
publish: =>
value = Rivets.Util.getInputValue @el
for formatter in @formatters.slice(0).reverse()
args = formatter.split /\s+/
id = args.shift()
if @view.formatters[id]?.publish
value = @view.formatters[id].publish value, args...
@view.adapters[@key.interface].publish @model, @key.path, value
# Subscribes to the model for changes at the specified keypath. Bi-directional
# routines will also listen for changes on the element to propagate them back
# to the model.
bind: (silent = false) =>
@binder.bind?.call @, @el unless silent
@view.adapters[@key.interface].subscribe(@model, @key.path, @sync) if @key
@sync() if @view.config.preloadData unless silent
if @options.dependencies?.length
for dependency in @options.dependencies
observer = new KeypathObserver @view, @model, dependency, (obs, prev) =>
key = obs.key
@view.adapters[key.interface].unsubscribe prev, key.path, @sync
@view.adapters[key.interface].subscribe obs.target, key.path, @sync
@sync()
key = observer.key
@view.adapters[key.interface].subscribe observer.target, key.path, @sync
@dependencies.push observer
# Unsubscribes from the model and the element.
unbind: (silent = false) =>
@binder.unbind?.call @, @el unless silent
@view.adapters[@key.interface].unsubscribe(@model, @key.path, @sync) if @key
if @dependencies.length
for obs in @dependencies
key = obs.key
@view.adapters[key.interface].unsubscribe obs.target, key.path, @sync
@dependencies = []
# Updates the binding's model from what is currently set on the view. Unbinds
# the old model first and then re-binds with the new model.
update: (models = {}) =>
@binder.update?.call @, models
# Rivets.ComponentBinding
# -----------------------
# A component view encapsulated as a binding within it's parent view.
class Rivets.ComponentBinding extends Rivets.Binding
# Initializes a component binding for the specified view. The raw component
# element is passed in along with the component type. Attributes and scope
# inflections are determined based on the components defined attributes.
constructor: (@view, @el, @type) ->
@component = Rivets.components[@type]
@attributes = {}
@inflections = {}
for attribute in @el.attributes or []
if attribute.name in @component.attributes
@attributes[attribute.name] = attribute.value
else
@inflections[attribute.name] = attribute.value
# Intercepts `Rivets.Binding::sync` since component bindings are not bound to
# a particular model to update it's value.
sync: ->
# Returns an object map using the component's scope inflections.
locals: (models = @view.models) =>
result = {}
for key, inverse of @inflections
result[key] = (result[key] or models)[path] for path in inverse.split '.'
result[key] ?= model for key, model of models
result
# Intercepts `Rivets.Binding::update` to be called on `@componentView` with a
# localized map of the models.
update: (models) =>
@componentView?.update @locals models
# Intercepts `Rivets.Binding::bind` to build `@componentView` with a localized
# map of models from the root view. Bind `@componentView` on subsequent calls.
bind: =>
if @componentView?
@componentView?.bind()
else
el = @component.build.call @attributes
(@componentView = new Rivets.View(el, @locals(), @view.options)).bind()
@el.parentNode.replaceChild el, @el
# Intercept `Rivets.Binding::unbind` to be called on `@componentView`.
unbind: =>
@componentView?.unbind()
# Rivets.TextBinding
# -----------------------
# A text node binding, defined internally to deal with text and element node
# differences while avoiding it being overwritten.
class Rivets.TextBinding extends Rivets.Binding
# Initializes a text binding for the specified view and text node.
constructor: (@view, @el, @type, @keypath, @options = {}) ->
@formatters = @options.formatters || []
@dependencies = []
@setObserver()
# A standard routine binder used for text node bindings.
binder:
routine: (node, value) ->
node.data = value ? ''
# Wrap the call to `sync` in fat-arrow to avoid function context issues.
sync: =>
super
# Rivets.factory
# --------------
# Rivets.js module factory.
Rivets.factory = (exports) ->
# Exposes the full Rivets namespace. This is mainly used for isolated testing.
exports._ = Rivets
# Exposes the binders object.
exports.binders = Rivets.binders
# Exposes the components object.
exports.components = Rivets.components
# Exposes the formatters object.
exports.formatters = Rivets.formatters
# Exposes the adapters object.
exports.adapters = Rivets.adapters
# Exposes the config object.
exports.config = Rivets.config
# Merges an object literal onto the config object.
exports.configure = (options={}) ->
for property, value of options
Rivets.config[property] = value
return
# Binds a set of model objects to a parent DOM element and returns a
# `Rivets.View` instance.
exports.bind = (el, models = {}, options = {}) ->
view = new Rivets.View(el, models, options)
view.bind()
view
# Exports Rivets.js for CommonJS, AMD and the browser.
if typeof exports == 'object'
Rivets.factory(exports)
else if typeof define == 'function' && define.amd
define ['exports'], (exports) ->
Rivets.factory(@rivets = exports)
return exports
else
Rivets.factory(@rivets = {})
class KeypathObserver
constructor: (@view, @model, @keypath, @callback) ->
@parse()
@objectPath = []
@target = @realize()
parse: =>
interfaces = (k for k, v of @view.adapters)
if @keypath[0] in interfaces
root = @keypath[0]
path = @keypath.substr 1
else
root = @view.config.rootInterface
path = @keypath
@tokens = Rivets.KeypathParser.parse path, interfaces, root
@key = @tokens.pop()
update: =>
unless (next = @realize()) is @target
prev = @target
@target = next
@callback @, prev
realize: =>
current = @model
for token, index in @tokens
if @objectPath[index]?
if current isnt prev = @objectPath[index]
@view.adapters[token.interface].unsubscribe prev, token.path, @update
@view.adapters[token.interface].subscribe current, token.path, @update
@objectPath[index] = current
else
@view.adapters[token.interface].subscribe current, token.path, @update
@objectPath[index] = current
current = @view.adapters[token.interface].read current, token.path
current
# Rivets.KeypathParser
# --------------------
# Parser and tokenizer for keypaths in binding declarations.
class Rivets.KeypathParser
# Parses the keypath and returns a set of adapter interface + path tokens.
@parse: (keypath, interfaces, root) ->
tokens = []
current = {interface: root, path: ''}
for index, char of keypath
if char in interfaces
tokens.push current
current = {interface: char, path: ''}
else
current.path += char
tokens.push current
tokens
# Rivets.TextTemplateParser
# -------------------------
# Rivets.js text template parser and tokenizer for mustache-style text content
# binding declarations.
class Rivets.TextTemplateParser
@types:
text: 0
binding: 1
# Parses the template and returns a set of tokens, separating static portions
# of text from binding declarations.
@parse: (template, delimiters) ->
tokens = []
length = template.length
index = 0
lastIndex = 0
while lastIndex < length
index = template.indexOf delimiters[0], lastIndex
if index < 0
tokens.push type: @types.text, value: template.slice lastIndex
break
else
if index > 0 and lastIndex < index
tokens.push type: @types.text, value: template.slice lastIndex, index
lastIndex = index + 2
index = template.indexOf delimiters[1], lastIndex
if index < 0
substring = template.slice lastIndex - 2
lastToken = tokens[tokens.length - 1]
if lastToken?.type is @types.text
lastToken.value += substring
else
tokens.push type: @types.text, value: substring
break
value = template.slice(lastIndex, index).trim()
tokens.push type: @types.binding, value: value
lastIndex = index + 2
tokens
# Rivets.Util
# -----------
# Houses common utility functions used internally by Rivets.js.
Rivets.Util =
# Create a single DOM event binding.
bindEvent: (el, event, handler) ->
if window.jQuery?
el = jQuery el
if el.on? then el.on event, handler else el.bind event, handler
else if window.addEventListener?
el.addEventListener event, handler, false
else
event = 'on' + event
el.attachEvent event, handler
# Remove a single DOM event binding.
unbindEvent: (el, event, handler) ->
if window.jQuery?
el = jQuery el
if el.off? then el.off event, handler else el.unbind event, handler
else if window.removeEventListener?
el.removeEventListener event, handler, false
else
event = 'on' + event
el.detachEvent event, handler
# Get the current value of an input node.
getInputValue: (el) ->
if window.jQuery?
el = jQuery el
switch el[0].type
when 'checkbox' then el.is ':checked'
else el.val()
else
switch el.type
when 'checkbox' then el.checked
when 'select-multiple' then o.value for o in el when o.selected
else el.value
# Rivets.View
# -----------
# A collection of bindings built from a set of parent nodes.
class Rivets.View
# The DOM elements and the model objects for binding are passed into the
# constructor along with any local options that should be used throughout the
# context of the view and it's bindings.
constructor: (@els, @models, @options = {}) ->
@els = [@els] unless (@els.jquery || @els instanceof Array)
for option in ['config', 'binders', 'formatters', 'adapters']
@[option] = {}
@[option][k] = v for k, v of @options[option] if @options[option]
@[option][k] ?= v for k, v of Rivets[option]
@build()
# Regular expression used to match binding attributes.
bindingRegExp: =>
new RegExp "^#{@config.prefix}-"
# Regular expression used to match component nodes.
componentRegExp: =>
new RegExp "^#{@config.prefix.toUpperCase()}-"
# Parses the DOM tree and builds `Rivets.Binding` instances for every matched
# binding declaration.
build: =>
@bindings = []
skipNodes = []
bindingRegExp = @bindingRegExp()
componentRegExp = @componentRegExp()
buildBinding = (binding, node, type, declaration) =>
options = {}
pipes = (pipe.trim() for pipe in declaration.split '|')
context = (ctx.trim() for ctx in pipes.shift().split '<')
keypath = context.shift()
options.formatters = pipes
if dependencies = context.shift()
options.dependencies = dependencies.split /\s+/
@bindings.push new Rivets[binding] @, node, type, keypath, options
parse = (node) =>
unless node in skipNodes
if node.nodeType is Node.TEXT_NODE
parser = Rivets.TextTemplateParser
if delimiters = @config.templateDelimiters
if (tokens = parser.parse(node.data, delimiters)).length
unless tokens.length is 1 and tokens[0].type is parser.types.text
[startToken, restTokens...] = tokens
node.data = startToken.value
if startToken.type is 0
node.data = startToken.value
else
buildBinding 'TextBinding', node, null, startToken.value
for token in restTokens
text = document.createTextNode token.value
node.parentNode.appendChild text
if token.type is 1
buildBinding 'TextBinding', text, null, token.value
else if componentRegExp.test node.tagName
type = node.tagName.replace(componentRegExp, '').toLowerCase()
@bindings.push new Rivets.ComponentBinding @, node, type
else if node.attributes?
for attribute in node.attributes
if bindingRegExp.test attribute.name
type = attribute.name.replace bindingRegExp, ''
unless binder = @binders[type]
for identifier, value of @binders
if identifier isnt '*' and identifier.indexOf('*') isnt -1
regexp = new RegExp "^#{identifier.replace('*', '.+')}$"
if regexp.test type
binder = value
binder or= @binders['*']
if binder.block
skipNodes.push n for n in node.childNodes
attributes = [attribute]
for attribute in attributes or node.attributes
if bindingRegExp.test attribute.name
type = attribute.name.replace bindingRegExp, ''
buildBinding 'Binding', node, type, attribute.value
parse childNode for childNode in node.childNodes
parse el for el in @els
return
# Returns an array of bindings where the supplied function evaluates to true.
select: (fn) =>
binding for binding in @bindings when fn binding
# Binds all of the current bindings for this view.
bind: =>
binding.bind() for binding in @bindings
# Unbinds all of the current bindings for this view.
unbind: =>
binding.unbind() for binding in @bindings
# Syncs up the view with the model by running the routines on all bindings.
sync: =>
binding.sync() for binding in @bindings
# Publishes the input values from the view back to the model (reverse sync).
publish: =>
binding.publish() for binding in @select (b) -> b.binder.publishes
# Updates the view's models along with any affected bindings.
update: (models = {}) =>
@models[key] = model for key, model of models
binding.update models for binding in @bindings