d0b185b9 by Michael Richards

Merge pull request #80 from mikeric/advanced-custom-binders-api

Advanced custom binders API
2 parents 4093da62 9b103172
......@@ -18,8 +18,8 @@ describe('Rivets.Binding', function() {
model = binding.model;
});
it('gets assigned the routine function matching the identifier', function() {
expect(binding.routine).toBe(rivets.routines.text);
it('gets assigned the proper binder routine matching the identifier', function() {
expect(binding.binder.routine).toBe(rivets.binders.text);
});
describe('bind()', function() {
......@@ -72,50 +72,24 @@ describe('Rivets.Binding', function() {
describe('set()', function() {
it('performs the binding routine with the supplied value', function() {
spyOn(binding, 'routine');
spyOn(binding.binder, 'routine');
binding.set('sweater');
expect(binding.routine).toHaveBeenCalledWith(el, 'sweater');
expect(binding.binder.routine).toHaveBeenCalledWith(el, 'sweater');
});
it('applies any formatters to the value before performing the routine', function() {
rivets.formatters.awesome = function(value) { return 'awesome ' + value };
binding.formatters.push('awesome');
spyOn(binding, 'routine');
spyOn(binding.binder, 'routine');
binding.set('sweater');
expect(binding.routine).toHaveBeenCalledWith(el, 'awesome sweater');
expect(binding.binder.routine).toHaveBeenCalledWith(el, 'awesome sweater');
});
it('calls methods with the object as context', function() {
binding.model = {foo: 'bar'};
spyOn(binding, 'routine');
spyOn(binding.binder, 'routine');
binding.set(function() { return this.foo; });
expect(binding.routine).toHaveBeenCalledWith(el, binding.model.foo);
});
describe('on an event binding', function() {
beforeEach(function() {
binding.options.special = 'event';
});
it('performs the binding routine with the supplied function and current listener', function() {
spyOn(binding, 'routine');
func = function() { return 1 + 2; }
binding.set(func);
expect(binding.routine).toHaveBeenCalledWith(el, binding.model, func, undefined);
});
});
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);
});
expect(binding.binder.routine).toHaveBeenCalledWith(el, binding.model.foo);
});
});
......
......@@ -18,13 +18,13 @@ describe('Routines', function() {
describe('text', function() {
it("sets the element's text content", function() {
rivets.routines.text(el, '<em>gluten-free</em>');
rivets.binders.text(el, '<em>gluten-free</em>');
expect(el.textContent || el.innerText).toBe('<em>gluten-free</em>');
expect(el.innerHTML).toBe('&lt;em&gt;gluten-free&lt;/em&gt;');
});
it("sets the element's text content to zero when a numeric zero is passed", function() {
rivets.routines.text(el, 0);
rivets.binders.text(el, 0);
expect(el.textContent || el.innerText).toBe('0');
expect(el.innerHTML).toBe('0');
});
......@@ -32,13 +32,13 @@ describe('Routines', function() {
describe('html', function() {
it("sets the element's HTML content", function() {
rivets.routines.html(el, '<strong>fixie</strong>');
rivets.binders.html(el, '<strong>fixie</strong>');
expect(el.textContent || el.innerText).toBe('fixie');
expect(el.innerHTML).toBe('<strong>fixie</strong>');
});
it("sets the element's HTML content to zero when a zero value is passed", function() {
rivets.routines.html(el, 0);
rivets.binders.html(el, 0);
expect(el.textContent || el.innerText).toBe('0');
expect(el.innerHTML).toBe('0');
});
......@@ -46,17 +46,17 @@ describe('Routines', function() {
describe('value', function() {
it("sets the element's value", function() {
rivets.routines.value(input, 'pitchfork');
rivets.binders.value.routine(input, 'pitchfork');
expect(input.value).toBe('pitchfork');
});
it("applies a default value to the element when the model doesn't contain it", function() {
rivets.routines.value(input, undefined);
rivets.binders.value.routine(input, undefined);
expect(input.value).toBe('');
});
it("sets the element's value to zero when a zero value is passed", function() {
rivets.routines.value(input, 0);
rivets.binders.value.routine(input, 0);
expect(input.value).toBe('0');
});
});
......@@ -64,14 +64,14 @@ describe('Routines', function() {
describe('show', function() {
describe('with a truthy value', function() {
it('shows the element', function() {
rivets.routines.show(el, true);
rivets.binders.show(el, true);
expect(el.style.display).toBe('');
});
});
describe('with a falsey value', function() {
it('hides the element', function() {
rivets.routines.show(el, false);
rivets.binders.show(el, false);
expect(el.style.display).toBe('none');
});
});
......@@ -80,14 +80,14 @@ describe('Routines', function() {
describe('hide', function() {
describe('with a truthy value', function() {
it('hides the element', function() {
rivets.routines.hide(el, true);
rivets.binders.hide(el, true);
expect(el.style.display).toBe('none');
});
});
describe('with a falsey value', function() {
it('shows the element', function() {
rivets.routines.hide(el, false);
rivets.binders.hide(el, false);
expect(el.style.display).toBe('');
});
});
......@@ -96,14 +96,14 @@ describe('Routines', function() {
describe('enabled', function() {
describe('with a truthy value', function() {
it('enables the element', function() {
rivets.routines.enabled(el, true);
rivets.binders.enabled(el, true);
expect(el.disabled).toBe(false);
});
});
describe('with a falsey value', function() {
it('disables the element', function() {
rivets.routines.enabled(el, false);
rivets.binders.enabled(el, false);
expect(el.disabled).toBe(true);
});
});
......@@ -112,14 +112,14 @@ describe('Routines', function() {
describe('disabled', function() {
describe('with a truthy value', function() {
it('disables the element', function() {
rivets.routines.disabled(el, true);
rivets.binders.disabled(el, true);
expect(el.disabled).toBe(true);
});
});
describe('with a falsey value', function() {
it('enables the element', function() {
rivets.routines.disabled(el, false);
rivets.binders.disabled(el, false);
expect(el.disabled).toBe(false);
});
});
......@@ -128,14 +128,14 @@ describe('Routines', function() {
describe('checked', function() {
describe('with a truthy value', function() {
it('checks the element', function() {
rivets.routines.checked(el, true);
rivets.binders.checked.routine(el, true);
expect(el.checked).toBe(true);
});
});
describe('with a falsey value', function() {
it('unchecks the element', function() {
rivets.routines.checked(el, false);
rivets.binders.checked.routine(el, false);
expect(el.checked).toBe(false);
});
});
......@@ -144,14 +144,14 @@ describe('Routines', function() {
describe('unchecked', function() {
describe('with a truthy value', function() {
it('unchecks the element', function() {
rivets.routines.unchecked(el, true);
rivets.binders.unchecked.routine(el, true);
expect(el.checked).toBe(false);
});
});
describe('with a falsey value', function() {
it('checks the element', function() {
rivets.routines.unchecked(el, false);
rivets.binders.unchecked.routine(el, false);
expect(el.checked).toBe(true);
});
});
......
......@@ -15,19 +15,21 @@ 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 = {}) ->
@routine = switch @options.special
when 'event' then eventBinding @type
when 'class' then classBinding @type
when 'iteration' then iterationBinding @type
else Rivets.routines[@type] || attributeBinding @type
unless @binder = Rivets.binders[type]
for identifier, value of Rivets.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()
@formatters = @options.formatters || []
@binder or= Rivets.binders['*']
if @binder instanceof Function
@binder = {routine: @binder}
# Returns true|false depending on whether or not the binding should also
# observe the DOM element for changes in order to propagate those changes
# back to the model object.
isBidirectional: =>
@type in ['value', 'checked', 'unchecked']
@formatters = @options.formatters || []
# Applies all the current formatters to the supplied value and returns the
# formatted value.
......@@ -45,17 +47,12 @@ class Rivets.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 @options.special isnt 'event'
value = if value instanceof Function and !@binder.function
@formattedValue value.call @model
else
@formattedValue value
if @options.special is 'event'
@currentListener = @routine @el, @model, value, @currentListener
else if @options.special is 'iteration'
@routine @el, value, @
else
@routine @el, value
@binder.routine?.call @, @el, value
# Syncs up the view binding with the model.
sync: =>
......@@ -75,12 +72,10 @@ class Rivets.Binding
if @options.bypass
@sync()
else
@binder.bind?.call @, @el
Rivets.config.adapter.subscribe @model, @keypath, @sync
@sync() if Rivets.config.preloadData
if @isBidirectional()
bindEvent @el, 'change', @publish
if @options.dependencies?.length
for dependency in @options.dependencies
if /^\./.test dependency
......@@ -93,15 +88,12 @@ class Rivets.Binding
Rivets.config.adapter.subscribe model, keypath, @sync
# Unsubscribes from the model and the element.
unbind: =>
unless @options.bypass
@binder.unbind?.call @, @el
Rivets.config.adapter.unsubscribe @model, @keypath, @sync
if @isBidirectional()
unbindEvent @el, 'change', @publish
if @options.dependencies?.length
for keypath in @options.dependencies
Rivets.config.adapter.unsubscribe @model, keypath, @sync
......@@ -119,28 +111,30 @@ class Rivets.View
prefix = Rivets.config.prefix
if prefix then new RegExp("^data-#{prefix}-") else /^data-/
# Builds the Rivets.Binding instances for the view.
build: =>
@bindings = []
skipNodes = []
iterator = null
bindingRegExp = @bindingRegExp()
eventRegExp = /^on-/
classRegExp = /^class-/
iterationRegExp = /^each-/
parseNode = (node) =>
unless node in skipNodes
for attribute in node.attributes
if bindingRegExp.test attribute.name
type = attribute.name.replace bindingRegExp, ''
unless binder = Rivets.binders[type]
for identifier, value of Rivets.binders
if identifier isnt '*' and identifier.indexOf('*') isnt -1
regexp = new RegExp "^#{identifier.replace('*', '.+')}$"
if regexp.test type
binder = value
if iterationRegExp.test type
unless @models[type.replace iterationRegExp, '']
skipNodes.push n for n in node.getElementsByTagName '*'
iterator = [attribute]
binder or= Rivets.binders['*']
for attribute in iterator or node.attributes
if binder.block
skipNodes.push n for n in node.getElementsByTagName '*'
attributes = [attribute]
for attribute in attributes or node.attributes
if bindingRegExp.test attribute.name
options = {}
......@@ -162,31 +156,19 @@ class Rivets.View
if dependencies = context.shift()
options.dependencies = dependencies.split /\s+/
if eventRegExp.test type
type = type.replace eventRegExp, ''
options.special = 'event'
if classRegExp.test type
type = type.replace classRegExp, ''
options.special = 'class'
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
attributes = null if attributes
return
for el in @els
parseNode el
parseNode node for node in el.getElementsByTagName '*'
return
# Returns an array of bindings where the supplied function evaluates to true.
......@@ -207,7 +189,7 @@ class Rivets.View
# Publishes the input values from the view back to the model (reverse sync).
publish: =>
binding.publish() for binding in @select (b) -> b.isBidirectional()
binding.publish() for binding in @select (b) -> b.binder.publishes
# Cross-browser event binding.
bindEvent = (el, event, handler, context) ->
......@@ -248,85 +230,111 @@ getInputValue = (el) ->
when 'select-multiple' then o.value for o in el when o.selected
else el.value
# Returns an event binding routine for the specified event.
eventBinding = (event) -> (el, context, bind, unbind) ->
unbindEvent el, event, unbind if unbind
bindEvent el, event, bind, context
# Returns a class binding routine for the specified class name.
classBinding = (name) -> (el, value) ->
elClass = " #{el.className} "
hasClass = elClass.indexOf(" #{name} ") != -1
if !value is hasClass
el.className = if value
"#{el.className} #{name}"
else
elClass.replace(" #{name} ", ' ').trim()
# 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 ? null
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) ->
if value then el.setAttribute attr, value else el.removeAttribute attr
# Core binding routines.
Rivets.routines =
Rivets.binders =
enabled: (el, value) ->
el.disabled = !value
disabled: (el, value) ->
el.disabled = !!value
checked: (el, value) ->
if el.type is 'radio'
el.checked = el.value is value
else
el.checked = !!value
unchecked: (el, value) ->
if el.type is 'radio'
el.checked = el.value isnt value
else
el.checked = !value
checked:
publishes: true
bind: (el) ->
bindEvent el, 'change', @publish
unbind: (el) ->
unbindEvent el, 'change', @publish
routine: (el, value) ->
if el.type is 'radio'
el.checked = el.value is value
else
el.checked = !!value
unchecked:
publishes: true
bind: (el) ->
bindEvent el, 'change', @publish
unbind: (el) ->
unbindEvent el, 'change', @publish
routine: (el, value) ->
if el.type is 'radio'
el.checked = el.value isnt value
else
el.checked = !value
show: (el, value) ->
el.style.display = if value then '' else 'none'
hide: (el, value) ->
el.style.display = if value then 'none' else ''
html: (el, value) ->
html: (el, value) ->
el.innerHTML = if value? then value else ''
value: (el, value) ->
if el.type is 'select-multiple'
o.selected = o.value in value for o in el if value?
else
el.value = if value? then value else ''
value:
publishes: true
bind: (el) ->
bindEvent el, 'change', @publish
unbind: (el) ->
unbindEvent el, 'change', @publish
routine: (el, value) ->
if el.type is 'select-multiple'
o.selected = o.value in value for o in el if value?
else
el.value = if value? then value else ''
text: (el, value) ->
if el.innerText?
el.innerText = if value? then value else ''
else
el.textContent = if value? then value else ''
"on-*":
function: true
routine: (el, value) ->
unbindEvent el, @args[0], @currentListener if @currentListener
@currentListener = bindEvent el, @args[0], value, @model
"each-*":
block: true
bind: (el, collection) ->
el.removeAttribute ['data', rivets.config.prefix, @type].join('-').replace '--', '-'
routine: (el, collection) ->
if @iterated?
for view in @iterated
view.unbind()
e.parentNode.removeChild e for e in view.els
else
@marker = document.createComment " rivets: #{@type} "
el.parentNode.insertBefore @marker, el
el.parentNode.removeChild el
@iterated = []
for item in collection
data = {}
data[n] = m for n, m of @view.models
data[@args[0]] = item
itemEl = el.cloneNode true
previous = @iterated[@iterated.length - 1] or @marker
@marker.parentNode.insertBefore itemEl, previous.nextSibling ? null
@iterated.push rivets.bind itemEl, data
"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()
"*": (el, value) ->
if value
el.setAttribute @type, value
else
el.removeAttribute @type
# Default configuration.
Rivets.config =
preloadData: true
......@@ -337,7 +345,7 @@ Rivets.formatters = {}
# The rivets module. This is the public interface that gets exported.
rivets =
# Exposes the core binding routines that can be extended or stripped down.
routines: Rivets.routines
binders: Rivets.binders
# Exposes the formatters object to be extended.
formatters: Rivets.formatters
......