浏览代码

Moved View#attributeBinder functionality to Template#bind.

Aleksander Nowodzinski 9 年之前
父节点
当前提交
d429431901
共有 2 个文件被更改,包括 780 次插入145 次删除
  1. 299 32
      packages/ckeditor5-ui/src/template.js
  2. 481 113
      packages/ckeditor5-ui/tests/template.js

+ 299 - 32
packages/ckeditor5-ui/src/template.js

@@ -8,6 +8,12 @@
 'use strict';
 
 import CKEditorError from '../utils/ckeditorerror.js';
+import mix from '../utils/mix.js';
+import EmitterMixin from '/ckeditor5/utils/emittermixin.js';
+
+const bindToSymbol = Symbol( 'bindTo' );
+const bindIfSymbol = Symbol( 'bindIf' );
+const bindDOMEvtSymbol = Symbol( 'bindDOMEvt' );
 
 /**
  * Basic Template class.
@@ -64,6 +70,10 @@ export default class Template {
 		return this._renderNode( this.definition, node );
 	}
 
+	destroy() {
+		this.stopListening();
+	}
+
 	/**
 	 * Renders a DOM Node from definition.
 	 *
@@ -139,25 +149,22 @@ export default class Template {
 	 * @param {HTMLElement} applyText If specified, template `def` will be applied to existing Text Node.
 	 * @returns {Text} A rendered Text.
 	 */
-	_renderText( defOrText, applyText ) {
+	_renderText( valueSchemaOrText, applyText ) {
 		const textNode = applyText || document.createTextNode( '' );
 
 		// Check if there's a binder available for this Text Node.
-		const binder = defOrText._modelBinders && defOrText._modelBinders.text;
-
-		// Activate binder if one. Cases:
-		//		{ text: bind.to( ... ) }
-		//		{ text: [ 'foo', bind.to( ... ), ... ] }
-		if ( binder ) {
-			binder( textNode, getTextNodeUpdater( textNode ) );
+		if ( hasModelBinding( valueSchemaOrText.text ) ) {
+			// Activate binder if one. Cases:
+			//		{ text: Template.bind.to( ... ) }
+			//		{ text: [ 'foo', Template.bind.to( ... ), ... ] }
+			this._setupBinding( valueSchemaOrText.text, textNode, getTextNodeUpdater( textNode ) );
 		}
-
 		// Simply set text. Cases:
 		// 		{ text: [ 'all', 'are', 'static' ] }
 		// 		{ text: 'foo' }
 		// 		'foo'
 		else {
-			textNode.textContent = defOrText.text || defOrText;
+			textNode.textContent = valueSchemaOrText.text || valueSchemaOrText;
 		}
 
 		return textNode;
@@ -186,11 +193,11 @@ export default class Template {
 			attrNs = attrValue.ns || null;
 
 			// Activate binder if one. Cases:
-			// 		{ class: [ 'bar', bind.to( ... ), 'baz' ] }
-			// 		{ class: bind.to( ... ) }
-			// 		{ class: { ns: 'abc', value: bind.to( ... ) } }
-			if ( binder ) {
-				binder( el, getElementAttributeUpdater( el, attrName, attrNs ) );
+			// 		{ class: [ 'bar', Template.bind.to( ... ), 'baz' ] }
+			// 		{ class: Template.bind.to( ... ) }
+			// 		{ class: { ns: 'abc', value: Template.bind.to( ... ) } }
+			if ( hasModelBinding( attrValue ) ) {
+				this._setupBinding( attrValue, el, getElementAttributeUpdater( el, attrName, attrNs ) );
 			}
 
 			// Otherwise simply set the attribute.
@@ -202,7 +209,7 @@ export default class Template {
 
 				// Attribute can be an array. Merge array elements:
 				if ( Array.isArray( attrValue ) ) {
-					attrValue = attrValue.reduce( function binderValueReducer( prev, cur ) {
+					attrValue = attrValue.reduce( ( prev, cur ) => {
 						return prev === '' ? `${cur}` : `${prev} ${cur}`;
 					} );
 				}
@@ -262,15 +269,207 @@ export default class Template {
 				}
 			} );
 	}
+
+	/**
+	 * For given {@link ui.TemplateValueSchema} containing {@link ui.TemplateBinding} it activates the
+	 * binding and sets its initial value.
+	 *
+	 * Note: {@link ui.TemplateValueSchema} can be for HTMLElement attributes or Text Node `textContent`.
+	 *
+	 * @protected
+	 * @param {ui.TemplateValueSchema}
+	 * @param {Node} node DOM Node to be updated when {@link View#model} changes.
+	 * @param {Function} domUpdater A function provided by {@link Template} which updates corresponding
+	 * DOM attribute or `textContent`.
+	 */
+	_setupBinding( valueSchema, node, domUpdater ) {
+		// Normalize attributes with additional data like namespace:
+		// class: { ns: 'abc', value: [ ... ] }
+		if ( valueSchema.value ) {
+			valueSchema = valueSchema.value;
+		}
+
+		valueSchema = normalizeBinderValueSchema( valueSchema );
+
+		// Assembles the value using {@link ui.TemplateValueSchema} and stores it in a form of
+		// an Array. Each entry of an Array corresponds to one of {@link ui.TemplateValueSchema}
+		// items.
+		//
+		// @private
+		// @param {Node} node
+		// @return {Array}
+		const getBoundValue = ( node ) => {
+			let model, modelValue;
+
+			return valueSchema.map( schemaItem => {
+				model = schemaItem.observable;
+
+				if ( model ) {
+					modelValue = model[ schemaItem.attribute ];
+
+					if ( schemaItem.callback ) {
+						modelValue = schemaItem.callback( modelValue, node );
+					}
+
+					if ( schemaItem.type === bindIfSymbol ) {
+						return !!modelValue ? schemaItem.valueIfTrue || true : '';
+					} else {
+						return modelValue;
+					}
+				} else {
+					return schemaItem;
+				}
+			} );
+		};
+
+		// A function executed each time bound Observable attribute changes, which updates DOM with a value
+		// constructed from {@link ui.TemplateValueSchema}.
+		const onObservableChange = () => {
+			let value = getBoundValue( node );
+			let shouldSet;
+
+			// Check if valueSchema is a single Template.bind.if, like:
+			//		{ class: Template.bind.if( 'foo' ) }
+			if ( valueSchema.length == 1 && valueSchema[ 0 ].type == bindIfSymbol ) {
+				value = value[ 0 ];
+				shouldSet = value !== '';
+
+				if ( shouldSet ) {
+					value = value === true ? '' : value;
+				}
+			} else {
+				value = value.reduce( binderValueReducer, '' );
+				shouldSet = value;
+			}
+
+			if ( shouldSet ) {
+				domUpdater.set( value );
+			} else {
+				domUpdater.remove();
+			}
+		};
+
+		valueSchema
+			.filter( schemaItem => schemaItem.observable )
+			.forEach( schemaItem => {
+				schemaItem.emitter.listenTo( schemaItem.observable, 'change:' + schemaItem.attribute, onObservableChange );
+			} );
+
+		// Set initial values.
+		onObservableChange();
+	}
 }
 
-// Returns an object consisting of `set` and `remove` functions, which
-// can be used in the context of DOM Node to set or reset `textContent`.
-// @see ui.View#_getModelBinder
-//
-// @private
-// @param {Node} node DOM Node to be modified.
-// @returns {Object}
+mix( Template, EmitterMixin );
+
+/**
+ * And entry point to the interface which allows binding attributes of {@link View#model}
+ * to the DOM items like HTMLElement attributes or Text Node `textContent`, so their state
+ * is synchronized with {@link View#model}.
+ *
+ * @readonly
+ * @type ui.TemplateBinding
+ */
+Template.bind = ( observable, emitter ) => {
+	const binderFunction = ( eventName ) => {
+		return {
+			type: bindDOMEvtSymbol,
+			observable, emitter,
+			eventName
+		};
+	};
+
+	/**
+	 * Binds {@link utils.ObservableMixin} to HTMLElement attribute or Text Node `textContent`
+	 * so remains in sync with the Model when it changes.
+	 *
+	 *		this.template = {
+	 *			tag: 'p',
+	 *			attributes: {
+	 *				// class="..." attribute gets bound to this.observable.a
+	 *				'class': Template.bind.to( 'a' )
+	 *			},
+	 *			children: [
+	 *				// <p>...</p> gets bound to this.observable.b; always `toUpperCase()`.
+	 *				{ text: Template.bind.to( 'b', ( value, node ) => value.toUpperCase() ) }
+	 *			]
+	 *		}
+	 *
+	 * @static
+	 * @property {attributeBinder.to}
+	 * @param {String} attribute Name of {@link utils.ObservableMixin} used in the binding.
+	 * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
+	 * @return {ui.TemplateBinding}
+	 */
+	binderFunction.to = ( attribute, callback ) => {
+		return {
+			type: bindToSymbol,
+			observable, emitter,
+			attribute, callback
+		};
+	};
+
+	/**
+	 * Binds {@link utils.ObservableMixin} to HTMLElement attribute or Text Node `textContent`
+	 * so remains in sync with the Model when it changes. Unlike {@link View#attributeBinder.to},
+	 * it controls the presence of the attribute/`textContent` depending on the "falseness" of
+	 * {@link utils.ObservableMixin} attribute.
+	 *
+	 *		this.template = {
+	 *			tag: 'input',
+	 *			attributes: {
+	 *				// <input checked> this.observable.a is not undefined/null/false/''
+	 *				// <input> this.observable.a is undefined/null/false
+	 *				checked: Template.bind.if( 'a' )
+	 *			},
+	 *			children: [
+	 *				{
+	 *					// <input>"b-is-not-set"</input> when this.observable.b is undefined/null/false/''
+	 *					// <input></input> when this.observable.b is not "falsy"
+	 *					text: Template.bind.if( 'b', 'b-is-not-set', ( value, node ) => !value )
+	 *				}
+	 *			]
+	 *		}
+	 *
+	 * @static
+	 * @property {attributeBinder.if}
+	 * @param {String} attribute Name of {@link utils.ObservableMixin} used in the binding.
+	 * @param {String} [valueIfTrue] Value set when {@link utils.ObservableMixin} attribute is not undefined/null/false/''.
+	 * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
+	 * @return {ui.TemplateBinding}
+	 */
+	binderFunction.if = ( attribute, valueIfTrue, callback ) => {
+		return {
+			type: bindIfSymbol,
+			observable, emitter,
+			attribute, valueIfTrue, callback
+		};
+	};
+
+	return binderFunction;
+};
+
+/**
+ * Describes Model binding created by {@link View#attributeBinder}.
+ *
+ * @typedef ui.TemplateBinding
+ * @type Object
+ * @property {Symbol} type
+ * @property {ui.Model} model
+ * @property {String} attribute
+ * @property {String} [valueIfTrue]
+ * @property {Function} [callback]
+ */
+
+/*
+ * Returns an object consisting of `set` and `remove` functions, which
+ * can be used in the context of DOM Node to set or reset `textContent`.
+ * @see ui.View#_setupBinding
+ *
+ * @private
+ * @param {Node} node DOM Node to be modified.
+ * @returns {Object}
+ */
 function getTextNodeUpdater( node ) {
 	return {
 		set( value ) {
@@ -283,15 +482,17 @@ function getTextNodeUpdater( node ) {
 	};
 }
 
-// Returns an object consisting of `set` and `remove` functions, which
-// can be used in the context of DOM Node to set or reset an attribute.
-// @see ui.View#_getModelBinder
-//
-// @private
-// @param {Node} node DOM Node to be modified.
-// @param {String} attrName Name of the attribute to be modified.
-// @param {String} [ns] Namespace to use.
-// @returns {Object}
+/*
+ * Returns an object consisting of `set` and `remove` functions, which
+ * can be used in the context of DOM Node to set or reset an attribute.
+ * @see ui.View#_setupBinding
+ *
+ * @private
+ * @param {Node} node DOM Node to be modified.
+ * @param {String} attrName Name of the attribute to be modified.
+ * @param {String} [ns] Namespace to use.
+ * @returns {Object}
+ */
 function getElementAttributeUpdater( el, attrName, ns = null ) {
 	return {
 		set( value ) {
@@ -305,6 +506,72 @@ function getElementAttributeUpdater( el, attrName, ns = null ) {
 }
 
 /**
+ * Normalizes given {@link ui.TemplateValueSchema} it's always in an Array–like format:
+ *
+ * 		{ attributeName/text: 'bar' } ->
+ * 			{ attributeName/text: [ 'bar' ] }
+ *
+ * 		{ attributeName/text: { model: ..., modelAttributeName: ..., callback: ... } } ->
+ * 			{ attributeName/text: [ { model: ..., modelAttributeName: ..., callback: ... } ] }
+ *
+ * 		{ attributeName/text: [ 'bar', { model: ..., modelAttributeName: ... }, 'baz' ] }
+ *
+ * @ignore
+ * @private
+ * @param {ui.TemplateValueSchema} valueSchema
+ * @returns {Array}
+ */
+function normalizeBinderValueSchema( valueSchema ) {
+	return Array.isArray( valueSchema ) ? valueSchema : [ valueSchema ];
+}
+
+/**
+ * A helper which concatenates the value avoiding unwanted
+ * leading white spaces.
+ *
+ * @ignore
+ * @private
+ * @param {String} prev
+ * @param {String} cur
+ * @returns {String}
+ */
+function binderValueReducer( prev, cur ) {
+	return prev === '' ?
+			`${cur}`
+		:
+			cur === '' ? `${prev}` : `${prev} ${cur}`;
+}
+
+/**
+ * Checks whether given {@link ui.TemplateValueSchema} contains a
+ * {@link ui.TemplateBinding}.
+ *
+ * @ignore
+ * @private
+ * @param {ui.TemplateValueSchema} valueSchema
+ * @returns {Boolean}
+ */
+function hasModelBinding( valueSchema ) {
+	if ( !valueSchema ) {
+		return false;
+	}
+
+	// Normalize attributes with additional data like namespace:
+	// class: { ns: 'abc', value: [ ... ] }
+	if ( valueSchema.value ) {
+		valueSchema = valueSchema.value;
+	}
+
+	if ( Array.isArray( valueSchema ) ) {
+		return valueSchema.some( hasModelBinding );
+	} else if ( valueSchema.observable ) {
+		return true;
+	}
+
+	return false;
+}
+
+/**
  * Definition of {@link Template}.
  * See: {@link ui.TemplateValueSchema}.
  *

+ 481 - 113
packages/ckeditor5-ui/tests/template.js

@@ -10,7 +10,10 @@
 
 import testUtils from '/tests/ckeditor5/_utils/utils.js';
 import Template from '/ckeditor5/ui/template.js';
+import Model from '/ckeditor5/ui/model.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import EmitterMixin from '/ckeditor5/utils/emittermixin.js';
+import extend from '/ckeditor5/utils/lib/lodash/extend.js';
 
 testUtils.createSinonSandbox();
 
@@ -260,112 +263,51 @@ describe( 'Template', () => {
 			sinon.assert.calledWithExactly( spy4, el, 'baz', '.y' );
 		} );
 
-		it( 'activates model bindings – attributes', () => {
-			const spy1 = testUtils.sinon.spy();
-			const spy2 = testUtils.sinon.spy();
+		it( 'activates model bindings – root', () => {
+			const observable = new Model( {
+				foo: 'bar'
+			} );
 
+			const emitter = extend( {}, EmitterMixin );
+			const bind = Template.bind( observable, emitter );
 			const el = new Template( {
-				tag: 'p',
+				tag: 'div',
 				attributes: {
-					'class': {}
-				},
-				children: [
-					{
-						tag: 'span',
-						attributes: {
-							id: {}
-						},
-						_modelBinders: {
-							attributes: {
-								id: spy2
-							}
-						}
-					}
-				],
-				_modelBinders: {
-					attributes: {
-						class: spy1
-					}
+					class: bind.to( 'foo' )
 				}
 			} ).render();
 
-			sinon.assert.calledWithExactly( spy1, el, sinon.match.object );
-			sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.object );
+			expect( el.getAttribute( 'class' ) ).to.equal( 'bar' );
+
+			observable.foo = 'baz';
+			expect( el.getAttribute( 'class' ) ).to.equal( 'baz' );
 		} );
 
-		it( 'activates model bindings – Text Node', () => {
-			const spy1 = testUtils.sinon.spy();
-			const spy2 = testUtils.sinon.spy();
+		it( 'activates model bindings – children', () => {
+			const observable = new Model( {
+				foo: 'bar'
+			} );
 
+			const emitter = extend( {}, EmitterMixin );
+			const bind = Template.bind( observable, emitter );
 			const el = new Template( {
-				tag: 'p',
+				tag: 'div',
 				children: [
 					{
-						text: {},
-						_modelBinders: {
-							text: spy1
-						}
-					},
-					{
 						tag: 'span',
 						children: [
 							{
-								text: {},
-								_modelBinders: {
-									text: spy2
-								}
+								text: bind.to( 'foo' )
 							}
 						]
 					}
 				]
 			} ).render();
 
-			sinon.assert.calledWithExactly( spy1, el.firstChild, sinon.match.object );
-			sinon.assert.calledWithExactly( spy2, el.lastChild.firstChild, sinon.match.object );
-		} );
+			expect( el.firstChild.textContent ).to.equal( 'bar' );
 
-		it( 'uses DOM updater – attributes', () => {
-			const spy = testUtils.sinon.spy();
-			const el = new Template( {
-				tag: 'p',
-				attributes: {
-					'class': {}
-				},
-				_modelBinders: {
-					attributes: {
-						class: spy
-					}
-				}
-			} ).render();
-
-			// Check whether DOM updater is correct.
-			spy.firstCall.args[ 1 ].set( 'x' );
-			expect( el.outerHTML ).to.be.equal( '<p class="x"></p>' );
-
-			spy.firstCall.args[ 1 ].remove();
-			expect( el.outerHTML ).to.be.equal( '<p></p>' );
-		} );
-
-		it( 'uses DOM updater – text', () => {
-			const spy = testUtils.sinon.spy();
-			const el = new Template( {
-				tag: 'p',
-				children: [
-					{
-						text: {},
-						_modelBinders: {
-							text: spy
-						}
-					}
-				],
-			} ).render();
-
-			// Check whether DOM updater is correct.
-			spy.firstCall.args[ 1 ].set( 'x' );
-			expect( el.outerHTML ).to.be.equal( '<p>x</p>' );
-
-			spy.firstCall.args[ 1 ].remove();
-			expect( el.outerHTML ).to.be.equal( '<p></p>' );
+			observable.foo = 'baz';
+			expect( el.firstChild.textContent ).to.equal( 'baz' );
 		} );
 	} );
 
@@ -511,47 +453,473 @@ describe( 'Template', () => {
 
 			sinon.assert.calledWithExactly( spy, el.firstChild, 'click', null );
 		} );
+	} );
 
-		it( 'activates model bindings – root', () => {
-			const spy = testUtils.sinon.spy();
+	describe( 'bind', () => {
+		it( 'returns function', () => {
+			expect( Template.bind() ).to.be.a( 'function' );
+		} );
 
-			new Template( {
-				tag: 'div',
-				attributes: {
-					class: {}
-				},
-				_modelBinders: {
-					attributes: {
-						class: spy
-					}
-				}
-			} ).apply( el );
+		it( 'provides "to" and "if" interface', () => {
+			const bind = Template.bind();
 
-			sinon.assert.calledWithExactly( spy, el, sinon.match.object );
+			expect( bind ).to.have.keys( 'to', 'if' );
+			expect( bind.to ).to.be.a( 'function' );
+			expect( bind.if ).to.be.a( 'function' );
 		} );
 
-		it( 'activates model bindings – children', () => {
-			const spy = testUtils.sinon.spy();
-			el.appendChild( document.createElement( 'span' ) );
+		describe( 'event', () => {
+		} );
 
-			new Template( {
-				tag: 'div',
-				children: [
-					{
-						tag: 'span',
+		describe( 'model', () => {
+			let observable, emitter, bind;
+
+			beforeEach( () => {
+				observable = new Model( {
+					foo: 'bar',
+					baz: 'qux'
+				} );
+
+				emitter = extend( {}, EmitterMixin );
+				bind = Template.bind( observable, emitter );
+			} );
+
+			describe( 'to', () => {
+				it( 'returns an object which describes the binding', () => {
+					const spy = testUtils.sinon.spy();
+					const binding = bind.to( 'foo', spy );
+
+					expect( spy.called ).to.be.false;
+					expect( binding ).to.have.keys( [ 'type', 'observable', 'emitter', 'attribute', 'callback' ] );
+					expect( binding.observable ).to.equal( observable );
+					expect( binding.callback ).to.equal( spy );
+					expect( binding.attribute ).to.equal( 'foo' );
+				} );
+
+				it( 'allows binding attribute to the observable – simple (HTMLElement attribute)', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo' )
+						},
+						children: [ 'abc' ]
+					} );
+
+					expect( element.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+
+					observable.foo = 'baz';
+					expect( element.outerHTML ).to.equal( '<p class="baz">abc</p>' );
+					expect( element.attributes.getNamedItem( 'class' ).namespaceURI ).to.be.null;
+				} );
+
+				it( 'allows binding attribute to the observable – simple (Text Node)', () => {
+					const element = getElement( {
+						tag: 'p',
+						children: [
+							{
+								text: bind.to( 'foo' )
+							}
+						]
+					} );
+
+					expect( element.outerHTML ).to.equal( '<p>bar</p>' );
+
+					observable.foo = 'baz';
+					expect( element.outerHTML ).to.equal( '<p>baz</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value processing', () => {
+					const callback = value => value > 0 ? 'positive' : 'negative';
+					const element = getElement( {
+						tag: 'p',
 						attributes: {
-							class: {}
+							'class': bind.to( 'foo', callback )
 						},
-						_modelBinders: {
-							attributes: {
-								class: spy
+						children: [
+							{
+								text: bind.to( 'foo', callback )
 							}
+						]
+					} );
+
+					observable.foo = 3;
+					expect( element.outerHTML ).to.equal( '<p class="positive">positive</p>' );
+
+					observable.foo = -7;
+					expect( element.outerHTML ).to.equal( '<p class="negative">negative</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value processing (use Node)', () => {
+					const callback = ( value, node ) => {
+						return ( !!node.tagName && value > 0 ) ? 'HTMLElement positive' : '';
+					};
+
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo', callback )
+						},
+						children: [
+							{
+								text: bind.to( 'foo', callback )
+							}
+						]
+					} );
+
+					observable.foo = 3;
+					expect( element.outerHTML ).to.equal( '<p class="HTMLElement positive"></p>' );
+
+					observable.foo = -7;
+					expect( element.outerHTML ).to.equal( '<p></p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – custom callback', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo', ( value, el ) => {
+								el.innerHTML = value;
+
+								if ( value == 'changed' ) {
+									return value;
+								}
+							} )
 						}
+					} );
+
+					observable.foo = 'moo';
+					expect( element.outerHTML ).to.equal( '<p class="undefined">moo</p>' );
+
+					observable.foo = 'changed';
+					expect( element.outerHTML ).to.equal( '<p class="changed">changed</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – array of bindings (HTMLElement attribute)', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': [
+								'ck-class',
+								bind.to( 'foo' ),
+								bind.to( 'baz' ),
+								bind.to( 'foo', value => `foo-is-${value}` ),
+								'ck-end'
+							]
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'a';
+					observable.baz = 'b';
+					expect( element.outerHTML ).to.equal( '<p class="ck-class a b foo-is-a ck-end">abc</p>' );
+
+					observable.foo = 'c';
+					observable.baz = 'd';
+					expect( element.outerHTML ).to.equal( '<p class="ck-class c d foo-is-c ck-end">abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – array of bindings (Text Node)', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+						},
+						children: [
+							{
+								text: [
+									'ck-class',
+									bind.to( 'foo' ),
+									bind.to( 'baz' ),
+									bind.to( 'foo', value => `foo-is-${value}` ),
+									'ck-end'
+								]
+							}
+						]
+					} );
+
+					observable.foo = 'a';
+					observable.baz = 'b';
+					expect( element.outerHTML ).to.equal( '<p>ck-class a b foo-is-a ck-end</p>' );
+
+					observable.foo = 'c';
+					observable.baz = 'd';
+					expect( element.outerHTML ).to.equal( '<p>ck-class c d foo-is-c ck-end</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – falsy values', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo' )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( element.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+
+					observable.foo = false;
+					expect( element.outerHTML ).to.equal( '<p class="false">abc</p>' );
+
+					observable.foo = null;
+					expect( element.outerHTML ).to.equal( '<p class="null">abc</p>' );
+
+					observable.foo = undefined;
+					expect( element.outerHTML ).to.equal( '<p class="undefined">abc</p>' );
+
+					observable.foo = 0;
+					expect( element.outerHTML ).to.equal( '<p class="0">abc</p>' );
+
+					observable.foo = '';
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – a custom namespace', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': {
+								ns: 'foo',
+								value: bind.to( 'foo' )
+							}
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( element.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+					expect( element.attributes.getNamedItem( 'class' ).namespaceURI ).to.equal( 'foo' );
+
+					observable.foo = 'baz';
+					expect( element.outerHTML ).to.equal( '<p class="baz">abc</p>' );
+					expect( element.attributes.getNamedItem( 'class' ).namespaceURI ).to.equal( 'foo' );
+				} );
+			} );
+
+			describe( 'if', () => {
+				it( 'returns an object which describes the binding', () => {
+					const spy = testUtils.sinon.spy();
+					const binding = bind.if( 'foo', 'whenTrue', spy );
+
+					expect( spy.called ).to.be.false;
+					expect( binding ).to.have.keys( [ 'type', 'observable', 'emitter', 'attribute', 'callback', 'valueIfTrue' ] );
+					expect( binding.observable ).to.equal( observable );
+					expect( binding.callback ).to.equal( spy );
+					expect( binding.attribute ).to.equal( 'foo' );
+					expect( binding.valueIfTrue ).to.equal( 'whenTrue' );
+				} );
+
+				it( 'allows binding attribute to the observable – presence of an attribute (HTMLElement attribute)', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo' )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = true;
+					expect( element.outerHTML ).to.equal( '<p class="">abc</p>' );
+
+					observable.foo = false;
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = 'bar';
+					expect( element.outerHTML ).to.equal( '<p class="">abc</p>' );
+				} );
+
+				// TODO: Is this alright? It makes sense but it's pretty useless. Text Node cannot be
+				// removed just like an attribute of some HTMLElement.
+				it( 'allows binding attribute to the observable – presence of an attribute (Text Node)', () => {
+					const element = getElement( {
+						tag: 'p',
+						children: [
+							{
+								text: bind.if( 'foo' )
+							}
+						]
+					} );
+
+					observable.foo = true;
+					expect( element.outerHTML ).to.equal( '<p></p>' );
+
+					observable.foo = false;
+					expect( element.outerHTML ).to.equal( '<p></p>' );
+
+					observable.foo = 'bar';
+					expect( element.outerHTML ).to.equal( '<p></p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value of an attribute (HTMLElement attribute)', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'bar' )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( element.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+
+					observable.foo = false;
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = 64;
+					expect( element.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value of an attribute (Text Node)', () => {
+					const element = getElement( {
+						tag: 'p',
+						children: [
+							{
+								text: bind.if( 'foo', 'bar' )
+							}
+						]
+					} );
+
+					observable.foo = 'bar';
+					expect( element.outerHTML ).to.equal( '<p>bar</p>' );
+
+					observable.foo = false;
+					expect( element.outerHTML ).to.equal( '<p></p>' );
+
+					observable.foo = 64;
+					expect( element.outerHTML ).to.equal( '<p>bar</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – array of bindings (HTMLElement attribute)', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': [
+								'ck-class',
+								bind.if( 'foo', 'foo-set' ),
+								bind.if( 'bar', 'bar-not-set', ( value ) => !value ),
+								'ck-end'
+							]
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = observable.bar = true;
+					expect( element.outerHTML ).to.equal( '<p class="ck-class foo-set ck-end">abc</p>' );
+
+					observable.foo = observable.bar = false;
+					expect( element.outerHTML ).to.equal( '<p class="ck-class bar-not-set ck-end">abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value of an attribute processed by a callback', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'there–is–no–foo', value => !value )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = false;
+					expect( element.outerHTML ).to.equal( '<p class="there–is–no–foo">abc</p>' );
+
+					observable.foo = 64;
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value of an attribute processed by a callback (use Node)', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'eqls-tag-name', ( value, el ) => el.tagName === value )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = 'P';
+					expect( element.outerHTML ).to.equal( '<p class="eqls-tag-name">abc</p>' );
+
+					observable.foo = 64;
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – falsy values', () => {
+					const element = getElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'foo-is-set' )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( element.outerHTML ).to.equal( '<p class="foo-is-set">abc</p>' );
+
+					observable.foo = false;
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = null;
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = undefined;
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = '';
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = 0;
+					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+				} );
+			} );
+
+			it( 'works with Template#apply() – root element', () => {
+				new Template( {
+					tag: 'div',
+					attributes: {
+						class: bind.to( 'foo' )
 					}
-				]
-			} ).apply( el );
+				} ).apply( el );
+
+				expect( el.getAttribute( 'class' ) ).to.equal( 'bar' );
+
+				observable.foo = 'baz';
+				expect( el.getAttribute( 'class' ) ).to.equal( 'baz' );
+			} );
 
-			sinon.assert.calledWithExactly( spy, el.firstChild, sinon.match.object );
+			it( 'works with Template#apply() – children', () => {
+				const el = document.createElement( 'div' );
+				const child = document.createElement( 'span' );
+
+				child.textContent = 'foo';
+				el.appendChild( child );
+
+				new Template( {
+					tag: 'div',
+					children: [
+						{
+							tag: 'span',
+							children: [
+								{
+									text: bind.to( 'foo' )
+								}
+							]
+						}
+					]
+				} ).apply( el );
+
+				expect( child.textContent ).to.equal( 'bar' );
+
+				observable.foo = 'baz';
+				expect( child.textContent ).to.equal( 'baz' );
+			} );
 		} );
 	} );
 } );
+
+function getElement( template ) {
+	return new Template( template ).render();
+}