8
0
Просмотр исходного кода

Merge pull request #89 from ckeditor/t/88

t/88: Allow using Views directly in TemplateDefinition.
Piotrek Koszuliński 9 лет назад
Родитель
Сommit
783d538304

+ 61 - 9
packages/ckeditor5-ui/src/controller.js

@@ -9,6 +9,8 @@ import CKEditorError from '../utils/ckeditorerror.js';
 import EmitterMixin from '../utils/emittermixin.js';
 import mix from '../utils/mix.js';
 
+const anon = '$anonymous';
+
 /**
  * Basic Controller class.
  *
@@ -54,6 +56,17 @@ export default class Controller {
 			idProperty: 'name'
 		} );
 
+		/**
+		 * Anonymous collection of this controller instance. It groups child controllers
+		 * which are not to be handled by `Controller#collections`–to–`View#region`
+		 * automation. It also means their views must be handled individually
+		 * by the view, i.e. passed as members of {@link ui.TemplateDefinition#children}.
+		 *
+		 * @protected
+		 * @member {ui.ControllerCollection} ui.Controller#_anonymousCollection
+		 */
+		this.collections.add( this._anonymousCollection = new ControllerCollection( anon ) );
+
 		// Listen to {@link ControllerCollection#add} and {@link ControllerCollection#remove}
 		// of newly added Collection to synchronize this controller's view and children
 		// controllers' views in the future.
@@ -169,23 +182,50 @@ export default class Controller {
 	/**
 	 * Adds a child {@link Controller} instance to {@link #collections} at given index.
 	 *
-	 * @param {String} collectionName Name of the Controller Collection.
-	 * @param {ui.Controller} controller A controller instance to be added.
+	 *		// Adds child to the specified collection. The collection name
+	 *		// must correspond with the region name in parent.view#regions.
+	 *		parent.add( 'collection-name', child );
+	 *
+	 *		// Adds child to the specified collection at specific index.
+	 *		// The collection name must correspond with the region name in parent.view#regions.
+	 *		parent.add( 'collection-name', child, 3 );
+	 *
+	 *		// Adds child to the {@link ui.Controller#_anonymousCollection} in the parent. In such case,
+	 *		// parent#view must put the child#view in the correct place in parent.view#template
+	 *		// because there's no association between the {@link ui.Controller#_anonymousCollection}
+	 *		// and any of the regions.
+	 *		parent.add( child );
+	 *
+	 * @param {String|ui.Controller} collectionNameOrController Name of the collection or the controller instance.
+	 * @param {ui.Controller} [controller] A controller instance to be added.
 	 * @param {Number} [index] An index in the collection.
+	 * @returns {Promise} A Promise resolved when the child {@link ui.Controller#init} is done.
 	 */
-	add( collectionName, controller, index ) {
-		this.collections.get( collectionName ).add( controller, index );
+	add( ...args ) {
+		if ( args[ 0 ] instanceof Controller ) {
+			return this._anonymousCollection.add( ...args );
+		} else {
+			return this.collections.get( args[ 0 ] ).add( args[ 1 ], args[ 2 ] );
+		}
 	}
 
 	/**
 	 * Removes a child {@link ui.Controller} instance from one of {@link ui.Controller#collections}.
 	 *
-	 * @param {String} collectionName Name of the Controller Collection.
-	 * @param {ui.Controller|Number} toRemove A Controller instance or index to be removed.
+	 * **Note**: To remove children from {@link ui.Controller#_anonymousCollection}, use the following syntax
+	 *
+	 *		parent.remove( child );
+	 *
+	 * @param {String|ui.Controller} collectionNameOrController Name of the collection or the controller instance.
+	 * @param {ui.Controller|Number} [toRemove] A Controller instance or index to be removed.
 	 * @returns {Object} The removed item.
 	 */
-	remove( collectionName, toRemove ) {
-		return this.collections.get( collectionName ).remove( toRemove );
+	remove( collectionNameOrController, toRemove ) {
+		if ( collectionNameOrController instanceof Controller ) {
+			return this._anonymousCollection.remove( collectionNameOrController );
+		} else {
+			return this.collections.get( collectionNameOrController ).remove( toRemove );
+		}
 	}
 
 	/**
@@ -216,7 +256,10 @@ export default class Controller {
 
 		for ( collection of this.collections ) {
 			for ( childController of collection ) {
-				if ( this.view && childController.view ) {
+				// Anonymous collection {@link ui.Controller#_anonymousCollection} does not allow
+				// automated controller-to-view binding, because there's no such thing as
+				// anonymous Region in the View instance.
+				if ( !isAnonymous( collection ) && this.view && childController.view ) {
 					this.view.regions.get( collection.name ).views.add( childController.view );
 				}
 
@@ -230,6 +273,15 @@ export default class Controller {
 
 mix( Controller, EmitterMixin );
 
+// Checks whether the collection is anonymous.
+//
+// @private
+// @param {ui.ControllerCollection} collection
+// @returns {Boolean}
+function isAnonymous( collection ) {
+	return collection.name == anon;
+}
+
 /**
  * Fired when the controller is fully initialized.
  *

+ 197 - 83
packages/ckeditor5-ui/src/template.js

@@ -9,16 +9,15 @@ import CKEditorError from '../utils/ckeditorerror.js';
 import mix from '../utils/mix.js';
 import EmitterMixin from '../utils/emittermixin.js';
 import Collection from '../utils/collection.js';
+import View from './view.js';
 import cloneDeepWith from '../utils/lib/lodash/cloneDeepWith.js';
 import isObject from '../utils/lib/lodash/isObject.js';
 
-const bindToSymbol = Symbol( 'bindTo' );
-const bindIfSymbol = Symbol( 'bindIf' );
 const xhtmlNs = 'http://www.w3.org/1999/xhtml';
 
 /**
  * A basic Template class. It renders DOM HTMLElement or Text from {@link ui.TemplateDefinition} and supports
- * element attributes, children, bindings to {@link utils.ObservableMixin} instances and DOM events
+ * element attributes, children, bindings to {@link utils.Observable} instances and DOM events
  * propagation. For example:
  *
  *		new Template( {
@@ -141,24 +140,24 @@ export default class Template {
 	}
 
 	/**
-	 * An entry point to the interface which allows binding DOM nodes to {@link utils.ObservableMixin}.
+	 * An entry point to the interface which allows binding DOM nodes to {@link utils.Observable}.
 	 * There are two types of bindings:
 	 *
-	 * * `HTMLElement` attributes or Text Node `textContent` can be synchronized with {@link utils.ObservableMixin}
+	 * * `HTMLElement` attributes or Text Node `textContent` can be synchronized with {@link utils.Observable}
 	 * instance attributes. See {@link ui.Template.bind#to} and {@link ui.Template.bind#if}.
 	 *
-	 * * DOM events fired on `HTMLElement` can be propagated through {@link utils.ObservableMixin}.
+	 * * DOM events fired on `HTMLElement` can be propagated through {@link utils.Observable}.
 	 * See {@link ui.Template.bind#to}.
 	 *
-	 * @param {utils.ObservableMixin} observable An instance of ObservableMixin class.
-	 * @param {utils.EmitterMixin} emitter An instance of `EmitterMixin` class. It listens
+	 * @param {utils.Observable} observable An instance of ObservableMixin class.
+	 * @param {utils.Emitter} emitter An instance of `Emitter` class. It listens
 	 * to `observable` attribute changes and DOM Events, depending on the binding. Usually {@link ui.View} instance.
 	 * @returns {Object}
 	 */
 	static bind( observable, emitter ) {
 		return {
 			/**
-			 * Binds {@link utils.ObservableMixin} instance to:
+			 * Binds {@link utils.Observable} instance to:
 			 *  * HTMLElement attribute or Text Node `textContent` so remains in sync with the Observable when it changes:
 			 *  * HTMLElement DOM event, so the DOM events are propagated through Observable.
 			 *
@@ -195,25 +194,24 @@ export default class Template {
 			 *
 			 * @static
 			 * @method ui.Template.bind#to
-			 * @param {String|Function} eventNameOrFunctionOrAttribute An attribute name of {@link utils.ObservableMixin} or a DOM
+			 * @param {String|Function} eventNameOrFunctionOrAttribute An attribute name of {@link utils.Observable} or a DOM
 			 * event name or an event callback.
 			 * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
 			 * @return {ui.TemplateBinding}
 			 */
 			to( eventNameOrFunctionOrAttribute, callback ) {
-				return {
-					type: bindToSymbol,
+				return new TemplateToBinding( {
 					eventNameOrFunction: eventNameOrFunctionOrAttribute,
 					attribute: eventNameOrFunctionOrAttribute,
 					observable, emitter, callback
-				};
+				} );
 			},
 
 			/**
-			 * Binds {@link utils.ObservableMixin} to HTMLElement attribute or Text Node `textContent`
+			 * Binds {@link utils.Observable} to HTMLElement attribute or Text Node `textContent`
 			 * so remains in sync with the Model when it changes. Unlike {@link ui.Template.bind#to},
 			 * it controls the presence of the attribute/`textContent` depending on the "falseness" of
-			 * {@link utils.ObservableMixin} attribute.
+			 * {@link utils.Observable} attribute.
 			 *
 			 *		const bind = Template.bind( observableInstance, emitterInstance );
 			 *
@@ -235,16 +233,15 @@ export default class Template {
 			 *
 			 * @static
 			 * @method ui.Template.bind#if
-			 * @param {String} attribute An 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 {String} attribute An attribute name of {@link utils.Observable} used in the binding.
+			 * @param {String} [valueIfTrue] Value set when {@link utils.Observable} attribute is not undefined/null/false/''.
 			 * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
 			 * @return {ui.TemplateBinding}
 			 */
 			if( attribute, valueIfTrue, callback ) {
-				return {
-					type: bindIfSymbol,
+				return new TemplateIfBinding( {
 					observable, emitter, attribute, valueIfTrue, callback
-				};
+				} );
 			}
 		};
 	}
@@ -381,7 +378,7 @@ export default class Template {
 		// Check if this Text Node is bound to Observable. Cases:
 		//		{ text: [ Template.bind( ... ).to( ... ) ] }
 		//		{ text: [ 'foo', Template.bind( ... ).to( ... ), ... ] }
-		if ( hasBinding( this.text ) ) {
+		if ( hasTemplateBinding( this.text ) ) {
 			this._bindToObservable( this.text, textNode, getTextUpdater( textNode ) );
 		}
 
@@ -419,7 +416,7 @@ export default class Template {
 			// 		{ class: [ Template.bind( ... ).to( ... ) ] }
 			// 		{ class: [ 'bar', Template.bind( ... ).to( ... ), 'baz' ] }
 			// 		{ class: { ns: 'abc', value: Template.bind( ... ).to( ... ) } }
-			if ( hasBinding( attrValue ) ) {
+			if ( hasTemplateBinding( attrValue ) ) {
 				this._bindToObservable(
 					// Normalize attributes with additional data like namespace:
 					//		{ class: { ns: 'abc', value: [ ... ] } }
@@ -491,7 +488,7 @@ export default class Template {
 			// style: {
 			//	color: bind.to( 'attribute' )
 			// }
-			if ( hasBinding( styleValue ) ) {
+			if ( hasTemplateBinding( styleValue ) ) {
 				this._bindToObservable( [ styleValue ], el, getStyleUpdater( el, styleName ) );
 			}
 
@@ -514,11 +511,17 @@ export default class Template {
 	_renderElementChildren( elOrDocFragment, shouldApply ) {
 		let childIndex = 0;
 
-		for ( let template of this.children ) {
-			if ( shouldApply ) {
-				template._renderNode( elOrDocFragment.childNodes[ childIndex++ ] );
+		for ( let child of this.children ) {
+			if ( isView( child ) ) {
+				if ( !shouldApply ) {
+					elOrDocFragment.appendChild( child.element );
+				}
 			} else {
-				elOrDocFragment.appendChild( template.render() );
+				if ( shouldApply ) {
+					child._renderNode( elOrDocFragment.childNodes[ childIndex++ ] );
+				} else {
+					elOrDocFragment.appendChild( child.render() );
+				}
 			}
 		}
 	}
@@ -537,17 +540,9 @@ export default class Template {
 		for ( let key in this.eventListeners ) {
 			const [ domEvtName, domSelector ] = key.split( '@' );
 
-			for ( let schemaItem of this.eventListeners[ key ] ) {
-				schemaItem.emitter.listenTo( el, domEvtName, ( evt, domEvt ) => {
-					if ( !domSelector || domEvt.target.matches( domSelector ) ) {
-						if ( typeof schemaItem.eventNameOrFunction == 'function' ) {
-							schemaItem.eventNameOrFunction( domEvt );
-						} else {
-							schemaItem.observable.fire( schemaItem.eventNameOrFunction, domEvt );
-						}
-					}
-				} );
-			}
+			this.eventListeners[ key ].forEach( schemaItem => {
+				schemaItem.activateDomEventListener( el, domEvtName, domSelector );
+			} );
 		}
 	}
 
@@ -559,7 +554,7 @@ export default class Template {
 	 *
 	 * @protected
 	 * @param {ui.TemplateValueSchema} valueSchema
-	 * @param {Node} node DOM Node to be updated when {@link utils.ObservableMixin} changes.
+	 * @param {Node} node DOM Node to be updated when {@link utils.Observable} changes.
 	 * @param {Function} domUpdater A function which updates DOM (like attribute or text).
 	 */
 	_bindToObservable( valueSchema ) {
@@ -571,25 +566,155 @@ export default class Template {
 			// Once only the actual binding are left, let the emitter listen to observable change:attribute event.
 			// TODO: Reduce the number of listeners attached as many bindings may listen
 			// to the same observable attribute.
-			.forEach( ( { emitter, observable, attribute } ) => {
-				emitter.listenTo( observable, 'change:' + attribute, () => {
-					syncBinding( ...arguments );
-				} );
-			} );
+			.forEach( templateBinding => templateBinding.activateAttributeListener( ...arguments ) );
 
 		// Set initial values.
-		syncBinding( ...arguments );
+		syncValueSchemaValue( ...arguments );
 	}
 }
 
 mix( Template, EmitterMixin );
 
+/**
+ * Describes a binding created by {@link ui.Template#bind} interface.
+ *
+ * @protected
+ * @memberOf ui
+ */
+export class TemplateBinding {
+	/**
+	 * Creates an instance of the {@link ui.TemplateBinding} class.
+	 *
+	 * @param {ui.TemplateBindingDefinition} def The definition of the binding.
+	 */
+	constructor( def ) {
+		Object.assign( this, def );
+
+		/**
+		 * An observable instance of the binding. It provides the attribute
+		 * with the value or passes the event when a corresponding DOM event is fired.
+		 *
+		 * @member {utils.Observable} ui.TemplateBinding#observable
+		 */
+
+		/**
+		 * An {@link utils.Emitter} instance used by the binding
+		 * to (either):
+		 *
+		 * * listen to the attribute change in the {@link ui.TemplateBinding#observable},
+		 * * listen to the event in the DOM.
+		 *
+		 * @member {utils.Emitter} ui.TemplateBinding#emitter
+		 */
+
+		/**
+		 * The name of the attribute of {@link ui.TemplateBinding#observable} which is observed.
+		 *
+		 * @member {String} ui.TemplateBinding#attribute
+		 */
+
+		/**
+		 * A custom function to process the value of {@link ui.TemplateBinding#attribute}.
+		 *
+		 * @member {Function} [ui.TemplateBinding#callback]
+		 */
+	}
+
+	/**
+	 * Returns the value of the binding, which is the value of {@link ui.TemplateBinding#attribute} in
+	 * {@link ui.TemplateBinding#observable}.
+	 *
+	 * @param {Node} [node] A native DOM node, passed to the custom {@link ui.TemplateBinding#callback}.
+	 * @returns {*} The value of {@link ui.TemplateBinding#attribute} in {@link ui.TemplateBinding#observable}.
+	 */
+	getValue( domNode ) {
+		const value = this.observable[ this.attribute ];
+
+		return this.callback ? this.callback( value, domNode ) : value;
+	}
+
+	/**
+	 * Activates the listener for the changes of {@link ui.TemplateBinding#attribute} in
+	 * {@link ui.TemplateBinding#observable}, which then updates the DOM with the aggregated
+	 * value of {@link ui.TemplateValueSchema}.
+	 *
+	 * For instance, the `class` attribute of the `Template` element can be be bound to
+	 * the observable `foo` attribute in `ObservableMixin` instance.
+	 *
+	 * @param {ui.TemplateValueSchema} valueSchema A full schema to generate an attribute or text in DOM.
+	 * @param {Node} node A native DOM node, which attribute or text is to be updated.
+	 * @param {Function} updater A DOM updater function used to update native DOM attribute or text.
+	 */
+	activateAttributeListener( valueSchema, node, updater ) {
+		this.emitter.listenTo( this.observable, 'change:' + this.attribute, () => {
+			syncValueSchemaValue( valueSchema, node, updater );
+		} );
+	}
+}
+
+/**
+ * Describes either
+ *
+ * * a binding to {@link utils.Observable}
+ * * or a native DOM event binding
+ *
+ * created by {@link ui.Template.bind#to} method.
+ *
+ * @protected
+ * @memberOf ui
+ */
+export class TemplateToBinding extends TemplateBinding {
+	/**
+	 * Activates the listener for the native DOM event, which when fired, is propagated by
+	 * the {@link ui.TemplateBinding#emitter}.
+	 *
+	 * @param {HTMLElement} element An element on which listening to the native DOM event.
+	 * @param {String} domEvtName A name of the native DOM event.
+	 * @param {String} [domSelector] A selector in DOM to filter delegated events.
+	 */
+	activateDomEventListener( el, domEvtName, domSelector ) {
+		this.emitter.listenTo( el, domEvtName, ( evt, domEvt ) => {
+			if ( !domSelector || domEvt.target.matches( domSelector ) ) {
+				if ( typeof this.eventNameOrFunction == 'function' ) {
+					this.eventNameOrFunction( domEvt );
+				} else {
+					this.observable.fire( this.eventNameOrFunction, domEvt );
+				}
+			}
+		} );
+	}
+}
+
+/**
+ * Describes a binding to {@link utils.Observable} created by {@link ui.Template.bind#if} method.
+ *
+ * @protected
+ * @memberOf ui
+ */
+export class TemplateIfBinding extends TemplateBinding {
+	/**
+	 * @inheritDoc
+	 */
+	getValue( domNode ) {
+		const value = super.getValue( domNode );
+
+		return isFalsy( value ) ? false : ( this.valueIfTrue || true );
+	}
+
+	/**
+	 * The value of the DOM attribute/text to be set if the {@link ui.TemplateBinding#attribute} in
+	 * {@link ui.TemplateBinding#observable} is `true`.
+	 *
+	 * @member {String} [ui.TemplateIfBinding#valueIfTrue]
+	 */
+}
+
 // Checks whether given {@link ui.TemplateValueSchema} contains a
 // {@link ui.TemplateBinding}.
 //
 // @param {ui.TemplateValueSchema} valueSchema
 // @returns {Boolean}
-function hasBinding( valueSchema ) {
+function hasTemplateBinding( valueSchema ) {
 	if ( !valueSchema ) {
 		return false;
 	}
@@ -601,8 +726,8 @@ function hasBinding( valueSchema ) {
 	}
 
 	if ( Array.isArray( valueSchema ) ) {
-		return valueSchema.some( hasBinding );
-	} else if ( valueSchema.observable ) {
+		return valueSchema.some( hasTemplateBinding );
+	} else if ( valueSchema instanceof TemplateBinding ) {
 		return true;
 	}
 
@@ -614,25 +739,13 @@ function hasBinding( valueSchema ) {
 // items.
 //
 // @param {ui.TemplateValueSchema} valueSchema
-// @param {Node} node DOM Node updated when {@link utils.ObservableMixin} changes.
+// @param {Node} node DOM Node updated when {@link utils.Observable} changes.
 // @return {Array}
-function getBindingValue( valueSchema, domNode ) {
+function getValueSchemaValue( valueSchema, domNode ) {
 	return valueSchema.map( schemaItem => {
 		// Process {@link ui.TemplateBinding} bindings.
-		if ( isObject( schemaItem ) ) {
-			let { observable, callback, type } = schemaItem;
-			let modelValue = observable[ schemaItem.attribute ];
-
-			// Process the value with the callback.
-			if ( callback ) {
-				modelValue = callback( modelValue, domNode );
-			}
-
-			if ( type === bindIfSymbol ) {
-				return isFalsy( modelValue ) ? false : ( schemaItem.valueIfTrue || true );
-			} else {
-				return modelValue;
-			}
+		if ( schemaItem instanceof TemplateBinding ) {
+			return schemaItem.getValue( domNode );
 		}
 
 		// All static values like strings, numbers, and "falsy" values (false, null, undefined, '', etc.) just pass.
@@ -644,14 +757,14 @@ function getBindingValue( valueSchema, domNode ) {
 // constructed from {@link ui.TemplateValueSchema}.
 //
 // @param {ui.TemplateValueSchema} valueSchema
-// @param {Node} node DOM Node updated when {@link utils.ObservableMixin} changes.
+// @param {Node} node DOM Node updated when {@link utils.Observable} changes.
 // @param {Function} domUpdater A function which updates DOM (like attribute or text).
-function syncBinding( valueSchema, domNode, domUpdater ) {
-	let value = getBindingValue( valueSchema, domNode );
+function syncValueSchemaValue( valueSchema, domNode, domUpdater ) {
+	let value = getValueSchemaValue( valueSchema, domNode );
 
 	// Check if valueSchema is a single Template.bind.if, like:
 	//		{ class: Template.bind.if( 'foo' ) }
-	if ( valueSchema.length == 1 && valueSchema[ 0 ].type == bindIfSymbol ) {
+	if ( valueSchema.length == 1 && valueSchema[ 0 ] instanceof TemplateIfBinding ) {
 		value = value[ 0 ];
 	} else {
 		value = value.reduce( arrayValueReducer, '' );
@@ -731,7 +844,11 @@ function clone( def ) {
 		// and DOMEmitterMixin instances inside, which would also be traversed and cloned by greedy
 		// cloneDeepWith algorithm. There's no point in cloning Observable/DOMEmitterMixins
 		// along with the definition.
-		if ( value && value.type ) {
+		//
+		// Also don't clone View instances if provided as a child of the Template. The template
+		// instance will be extracted from the View during the normalization and there's no need
+		// to clone it.
+		if ( value && ( value instanceof TemplateBinding || isView( value ) ) ) {
 			return value;
 		}
 	} );
@@ -772,7 +889,7 @@ function normalize( def ) {
 
 		if ( def.children ) {
 			for ( let child of def.children ) {
-				children.add( new Template( child ) );
+				children.add( isView( child ) ? child : new Template( child ) );
 			}
 		}
 
@@ -985,11 +1102,20 @@ function extendTemplate( template, def ) {
 // Checks if value is "falsy".
 // Note: 0 (Number) is not "falsy" in this context.
 //
+// @private
 // @param {*} value Value to be checked.
 function isFalsy( value ) {
 	return !value && value !== 0;
 }
 
+// Checks if the item is an instance of {@link ui.View}
+//
+// @private
+// @param {*} value Value to be checked.
+function isView( item ) {
+	return item instanceof View;
+}
+
 /**
  * A definition of {@link ui.Template}.
  * See: {@link ui.TemplateValueSchema}.
@@ -1007,6 +1133,7 @@ function isFalsy( value ) {
  *					text: 'static–text'
  *				},
  *				'also-static–text',
+ *				<{@link ui.View} instance>
  *				...
  *			],
  *			attributes: {
@@ -1105,16 +1232,3 @@ function isFalsy( value ) {
  * @typedef ui.TemplateListenerSchema
  * @type {Object|String|Array}
  */
-
-/**
- * Describes Model binding created via {@link ui.Template#bind}.
- *
- * @typedef ui.TemplateBinding
- * @type Object
- * @property {utils.ObservableMixin} observable
- * @property {utils.EmitterMixin} emitter
- * @property {Symbol} type
- * @property {String} attribute
- * @property {String} [valueIfTrue]
- * @property {Function} [callback]
- */

+ 128 - 19
packages/ckeditor5-ui/tests/controller.js

@@ -26,7 +26,9 @@ describe( 'Controller', () => {
 			expect( controller.model ).to.be.null;
 			expect( controller.ready ).to.be.false;
 			expect( controller.view ).to.be.null;
-			expect( controller.collections.length ).to.be.equal( 0 );
+
+			expect( controller.collections.length ).to.equal( 1 );
+			expect( controller.collections.get( '$anonymous' ) ).to.be.instanceOf( ControllerCollection );
 		} );
 
 		it( 'should accept model and view', () => {
@@ -34,8 +36,8 @@ describe( 'Controller', () => {
 			const view = new View();
 			const controller = new Controller( model, view );
 
-			expect( controller.model ).to.be.equal( model );
-			expect( controller.view ).to.be.equal( view );
+			expect( controller.model ).to.equal( model );
+			expect( controller.view ).to.equal( view );
 		} );
 	} );
 
@@ -92,8 +94,34 @@ describe( 'Controller', () => {
 			buttonCollection.add( childController2 );
 
 			return parentController.init().then( () => {
-				expect( buttonCollection.get( 0 ) ).to.be.equal( childController1 );
-				expect( buttonCollection.get( 1 ) ).to.be.equal( childController2 );
+				expect( buttonCollection.get( 0 ) ).to.equal( childController1 );
+				expect( buttonCollection.get( 1 ) ).to.equal( childController2 );
+
+				sinon.assert.calledOnce( spy1 );
+				sinon.assert.calledOnce( spy2 );
+			} );
+		} );
+
+		it( 'should initialize child controllers in anonymous collection', () => {
+			const parentController = new Controller( null, new View() );
+			const child1 = new Controller( null, new View() );
+			const child2 = new Controller( null, new View() );
+
+			const spy1 = testUtils.sinon.spy( child1, 'init' );
+			const spy2 = testUtils.sinon.spy( child2, 'init' );
+
+			const collection = parentController.collections.get( '$anonymous' );
+
+			parentController.add( child1 );
+			parentController.add( child2 );
+
+			expect( collection ).to.have.length( 2 );
+			expect( collection.get( 0 ) ).to.equal( child1 );
+			expect( collection.get( 1 ) ).to.equal( child2 );
+
+			return parentController.init().then( () => {
+				expect( collection.get( 0 ) ).to.equal( child1 );
+				expect( collection.get( 1 ) ).to.equal( child2 );
 
 				sinon.assert.calledOnce( spy1 );
 				sinon.assert.calledOnce( spy2 );
@@ -104,6 +132,12 @@ describe( 'Controller', () => {
 	describe( 'add', () => {
 		beforeEach( defineParentControllerClass );
 
+		it( 'should return a promise', () => {
+			const parentController = new ParentController();
+
+			expect( parentController.add( 'x', new Controller() ) ).to.be.an.instanceof( Promise );
+		} );
+
 		it( 'should add a controller to specific collection', () => {
 			const parentController = new ParentController();
 			const child1 = new Controller();
@@ -114,8 +148,8 @@ describe( 'Controller', () => {
 			parentController.add( 'x', child2 );
 
 			expect( collection ).to.have.length( 2 );
-			expect( collection.get( 0 ) ).to.be.equal( child1 );
-			expect( collection.get( 1 ) ).to.be.equal( child2 );
+			expect( collection.get( 0 ) ).to.equal( child1 );
+			expect( collection.get( 1 ) ).to.equal( child2 );
 		} );
 
 		it( 'should add a controller at specific index', () => {
@@ -128,8 +162,23 @@ describe( 'Controller', () => {
 			parentController.add( 'x', child2, 0 );
 
 			expect( collection ).to.have.length( 2 );
-			expect( collection.get( 0 ) ).to.be.equal( child2 );
-			expect( collection.get( 1 ) ).to.be.equal( child1 );
+			expect( collection.get( 0 ) ).to.equal( child2 );
+			expect( collection.get( 1 ) ).to.equal( child1 );
+		} );
+
+		it( 'should add a controller to the anonymous collection', () => {
+			const parentController = new ParentController( null, new View() );
+			const child1 = new Controller( null, new View() );
+			const child2 = new Controller( null, new View() );
+
+			const collection = parentController.collections.get( '$anonymous' );
+
+			parentController.add( child1 );
+			parentController.add( child2 );
+
+			expect( collection ).to.have.length( 2 );
+			expect( collection.get( 0 ) ).to.equal( child1 );
+			expect( collection.get( 1 ) ).to.equal( child2 );
 		} );
 	} );
 
@@ -150,9 +199,9 @@ describe( 'Controller', () => {
 			const removed = parentController.remove( 'x', child2 );
 
 			expect( collection ).to.have.length( 2 );
-			expect( collection.get( 0 ) ).to.be.equal( child1 );
-			expect( collection.get( 1 ) ).to.be.equal( child3 );
-			expect( removed ).to.be.equal( child2 );
+			expect( collection.get( 0 ) ).to.equal( child1 );
+			expect( collection.get( 1 ) ).to.equal( child3 );
+			expect( removed ).to.equal( child2 );
 		} );
 
 		it( 'should remove a controller from specific collection – by index', () => {
@@ -169,9 +218,28 @@ describe( 'Controller', () => {
 			const removed = parentController.remove( 'x', 1 );
 
 			expect( collection ).to.have.length( 2 );
-			expect( collection.get( 0 ) ).to.be.equal( child1 );
-			expect( collection.get( 1 ) ).to.be.equal( child3 );
-			expect( removed ).to.be.equal( child2 );
+			expect( collection.get( 0 ) ).to.equal( child1 );
+			expect( collection.get( 1 ) ).to.equal( child3 );
+			expect( removed ).to.equal( child2 );
+		} );
+
+		it( 'should remove a controller from the anonymous collection', () => {
+			const parentController = new ParentController();
+			const child1 = new Controller();
+			const child2 = new Controller();
+			const child3 = new Controller();
+
+			parentController.add( child1 );
+			parentController.add( child2 );
+			parentController.add( child3 );
+
+			const collection = parentController.collections.get( '$anonymous' );
+			const removed = parentController.remove( child2 );
+
+			expect( collection ).to.have.length( 2 );
+			expect( collection.get( 0 ) ).to.equal( child1 );
+			expect( collection.get( 1 ) ).to.equal( child3 );
+			expect( removed ).to.equal( child2 );
 		} );
 	} );
 
@@ -190,7 +258,7 @@ describe( 'Controller', () => {
 						return collection.add( childController );
 					} )
 					.then( () => {
-						expect( collection.get( 0 ) ).to.be.equal( childController );
+						expect( collection.get( 0 ) ).to.equal( childController );
 					} );
 			} );
 
@@ -238,8 +306,29 @@ describe( 'Controller', () => {
 					.then( () => {
 						const region = parentController.view.regions.get( 'x' );
 
-						expect( region.views.get( 0 ) ).to.be.equal( childView2 );
-						expect( region.views.get( 1 ) ).to.be.equal( childView1 );
+						expect( region.views.get( 0 ) ).to.equal( childView2 );
+						expect( region.views.get( 1 ) ).to.equal( childView1 );
+					} );
+			} );
+
+			it( 'should not handle views of anonymous collection children', () => {
+				const parentController = new ParentController( null, new ParentView() );
+
+				const childView1 = new View();
+				const childController1 = new Controller( null, childView1 );
+				const childView2 = new View();
+				const childController2 = new Controller( null, childView2 );
+
+				return parentController.init()
+					.then( () => {
+						return parentController.add( childController1 ).then( () => {
+							return parentController.add( childController2 );
+						} );
+					} )
+					.then( () => {
+						const region = parentController.view.regions.get( 'x' );
+
+						expect( region.views ).to.have.length( 0 );
 					} );
 			} );
 		} );
@@ -346,6 +435,26 @@ describe( 'Controller', () => {
 				} );
 		} );
 
+		it( 'should destroy child controllers in anonymous collection along with their views', () => {
+			const parentController = new ParentController( null, new ParentView() );
+			const childView = new View();
+			const childController = new Controller( null, childView );
+			const spy = testUtils.sinon.spy( childView, 'destroy' );
+
+			parentController.add( childController );
+
+			return parentController.init()
+				.then( () => {
+					return parentController.destroy();
+				} )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+					expect( childController.model ).to.be.null;
+					expect( childController.view ).to.be.null;
+					expect( childController.collections ).to.be.null;
+				} );
+		} );
+
 		// See #11
 		it( 'should correctly destroy multiple controller collections', () => {
 			const parentController = new Controller();
@@ -414,7 +523,7 @@ describe( 'Controller', () => {
 
 			controller.addCollection( 'foo' );
 
-			expect( controller.collections ).to.have.length( 1 );
+			expect( controller.collections ).to.have.length( 2 );
 			expect( controller.collections.get( 'foo' ).name ).to.equal( 'foo' );
 		} );
 

+ 462 - 381
packages/ckeditor5-ui/tests/template.js

@@ -8,6 +8,8 @@
 
 import testUtils from '/tests/core/_utils/utils.js';
 import Template from '/ckeditor5/ui/template.js';
+import { TemplateToBinding, TemplateIfBinding } from '/ckeditor5/ui/template.js';
+import View from '/ckeditor5/ui/view.js';
 import Model from '/ckeditor5/ui/model.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 import EmitterMixin from '/ckeditor5/utils/emittermixin.js';
@@ -58,19 +60,19 @@ describe( 'Template', () => {
 			expect( tpl.attributes.a[ 0 ] ).to.equal( 'foo' );
 			expect( tpl.attributes.b[ 0 ] ).to.equal( 'bar' );
 			expect( tpl.attributes.b[ 1 ] ).to.equal( 'baz' );
-			expect( tpl.attributes.c[ 0 ].value[ 0 ].type ).to.be.a( 'symbol' );
+			expect( tpl.attributes.c[ 0 ].value[ 0 ] ).to.be.instanceof( TemplateToBinding );
 
 			expect( tpl.children ).to.have.length( 4 );
 			expect( tpl.children.get( 0 ).text[ 0 ] ).to.equal( 'content' );
-			expect( tpl.children.get( 1 ).text[ 0 ].type ).to.be.a( 'symbol' );
+			expect( tpl.children.get( 1 ).text[ 0 ] ).to.be.instanceof( TemplateToBinding );
 			expect( tpl.children.get( 2 ).text[ 0 ] ).to.equal( 'abc' );
 			expect( tpl.children.get( 3 ).text[ 0 ] ).to.equal( 'a' );
 			expect( tpl.children.get( 3 ).text[ 1 ] ).to.equal( 'b' );
 
-			expect( tpl.eventListeners[ 'a@span' ][ 0 ].type ).to.be.a( 'symbol' );
-			expect( tpl.eventListeners[ 'b@span' ][ 0 ].type ).to.be.a( 'symbol' );
-			expect( tpl.eventListeners[ 'c@span' ][ 0 ].type ).to.be.a( 'symbol' );
-			expect( tpl.eventListeners[ 'c@span' ][ 1 ].type ).to.be.a( 'symbol' );
+			expect( tpl.eventListeners[ 'a@span' ][ 0 ] ).to.be.instanceof( TemplateToBinding );
+			expect( tpl.eventListeners[ 'b@span' ][ 0 ] ).to.be.instanceof( TemplateToBinding );
+			expect( tpl.eventListeners[ 'c@span' ][ 0 ] ).to.be.instanceof( TemplateToBinding );
+			expect( tpl.eventListeners[ 'c@span' ][ 1 ] ).to.be.instanceof( TemplateToBinding );
 
 			// Note that Template mixes EmitterMixin.
 			expect( tpl.on ).to.be.a( 'function' );
@@ -131,323 +133,368 @@ describe( 'Template', () => {
 			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
 		} );
 
-		it( 'creates HTMLElement', () => {
-			const el = new Template( {
-				tag: 'p',
-			} ).render();
+		describe( 'DOM Node', () => {
+			it( 'creates HTMLElement', () => {
+				const el = new Template( {
+					tag: 'p',
+				} ).render();
 
-			expect( el ).to.be.instanceof( HTMLElement );
-			expect( el.parentNode ).to.be.null;
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p></p>' );
-			expect( el.namespaceURI ).to.equal( 'http://www.w3.org/1999/xhtml' );
-		} );
+				expect( el ).to.be.instanceof( HTMLElement );
+				expect( el.parentNode ).to.be.null;
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p></p>' );
+				expect( el.namespaceURI ).to.equal( 'http://www.w3.org/1999/xhtml' );
+			} );
 
-		it( 'creates an element in a custom namespace', () => {
-			const el = new Template( {
-				tag: 'p',
-				ns: 'foo'
-			} ).render();
+			it( 'creates an element in a custom namespace', () => {
+				const el = new Template( {
+					tag: 'p',
+					ns: 'foo'
+				} ).render();
 
-			expect( el.namespaceURI ).to.equal( 'foo' );
-		} );
+				expect( el.namespaceURI ).to.equal( 'foo' );
+			} );
 
-		it( 'renders HTMLElement attributes', () => {
-			const el = new Template( {
-				tag: 'p',
-				attributes: {
-					'class': [ 'a', 'b' ],
-					x: 'bar'
-				},
-				children: [ 'foo' ]
-			} ).render();
+			it( 'creates a Text node', () => {
+				const node = new Template( { text: 'foo' } ).render();
 
-			expect( el ).to.be.instanceof( HTMLElement );
-			expect( el.parentNode ).to.be.null;
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p class="a b" x="bar">foo</p>' );
-			expect( el.attributes.getNamedItem( 'class' ).namespaceURI ).to.be.null;
+				expect( node.nodeType ).to.equal( 3 );
+				expect( node.textContent ).to.equal( 'foo' );
+			} );
 		} );
 
-		it( 'renders HTMLElement attributes – empty', () => {
-			const el = new Template( {
-				tag: 'p',
-				attributes: {
-					class: '',
-					x: [ '', '' ]
-				},
-				children: [ 'foo' ]
-			} ).render();
+		describe( 'attributes', () => {
+			it( 'renders HTMLElement attributes', () => {
+				const el = new Template( {
+					tag: 'p',
+					attributes: {
+						'class': [ 'a', 'b' ],
+						x: 'bar'
+					},
+					children: [ 'foo' ]
+				} ).render();
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p>foo</p>' );
-		} );
+				expect( el ).to.be.instanceof( HTMLElement );
+				expect( el.parentNode ).to.be.null;
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p class="a b" x="bar">foo</p>' );
+				expect( el.attributes.getNamedItem( 'class' ).namespaceURI ).to.be.null;
+			} );
 
-		it( 'renders HTMLElement attributes – falsy values', () => {
-			const el = new Template( {
-				tag: 'p',
-				attributes: {
-					class: false,
-					x: [ '', null, undefined, false ],
-					y: [ 'foo', null, undefined, false ]
-				},
-				children: [ 'foo' ]
-			} ).render();
+			it( 'renders HTMLElement attributes – empty', () => {
+				const el = new Template( {
+					tag: 'p',
+					attributes: {
+						class: '',
+						x: [ '', '' ]
+					},
+					children: [ 'foo' ]
+				} ).render();
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p y="foo">foo</p>' );
-		} );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p>foo</p>' );
+			} );
 
-		it( 'renders HTMLElement attributes in a custom namespace', () => {
-			const el = new Template( {
-				tag: 'p',
-				attributes: {
-					class: {
-						ns: 'foo',
-						value: [ 'a', 'b' ]
+			it( 'renders HTMLElement attributes – falsy values', () => {
+				const el = new Template( {
+					tag: 'p',
+					attributes: {
+						class: false,
+						x: [ '', null, undefined, false ],
+						y: [ 'foo', null, undefined, false ]
 					},
-					x: {
-						ns: 'abc',
-						value: 'bar'
-					}
-				},
-				children: [ 'foo' ]
-			} ).render();
+					children: [ 'foo' ]
+				} ).render();
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p class="a b" x="bar">foo</p>' );
-			expect( el.attributes.getNamedItem( 'class' ).namespaceURI ).to.equal( 'foo' );
-			expect( el.attributes.getNamedItem( 'x' ).namespaceURI ).to.equal( 'abc' );
-		} );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p y="foo">foo</p>' );
+			} );
 
-		it( 'creates HTMLElement children', () => {
-			const el = new Template( {
-				tag: 'p',
-				attributes: {
-					a: 'A'
-				},
-				children: [
-					{
-						tag: 'b',
-						children: [ 'B' ]
+			it( 'renders HTMLElement attributes in a custom namespace', () => {
+				const el = new Template( {
+					tag: 'p',
+					attributes: {
+						class: {
+							ns: 'foo',
+							value: [ 'a', 'b' ]
+						},
+						x: {
+							ns: 'abc',
+							value: 'bar'
+						}
 					},
-					{
-						tag: 'i',
-						children: [
-							'C',
-							{
-								tag: 'b',
-								children: [ 'D' ]
-							}
-						]
-					}
-				]
-			} ).render();
+					children: [ 'foo' ]
+				} ).render();
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p a="A"><b>B</b><i>C<b>D</b></i></p>' );
-		} );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p class="a b" x="bar">foo</p>' );
+				expect( el.attributes.getNamedItem( 'class' ).namespaceURI ).to.equal( 'foo' );
+				expect( el.attributes.getNamedItem( 'x' ).namespaceURI ).to.equal( 'abc' );
+			} );
 
-		it( 'creates a Text node', () => {
-			const node = new Template( { text: 'foo' } ).render();
+			describe( 'style', () => {
+				let observable, emitter, bind;
 
-			expect( node.nodeType ).to.equal( 3 );
-			expect( node.textContent ).to.equal( 'foo' );
-		} );
+				beforeEach( () => {
+					observable = new Model( {
+						width: '10px',
+						backgroundColor: 'yellow'
+					} );
 
-		it( 'creates a child Text Node (different syntaxes)', () => {
-			const el = new Template( {
-				tag: 'p',
-				children: [
-					'foo',
-					{ text: 'bar' }
-				]
-			} ).render();
+					emitter = Object.create( EmitterMixin );
+					bind = Template.bind( observable, emitter );
+				} );
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p>foobar</p>' );
-		} );
+				it( 'renders as a static value', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: 'color: red'
+						}
+					} );
 
-		it( 'creates multiple child Text Nodes', () => {
-			const el = new Template( {
-				tag: 'p',
-				children: [
-					'a',
-					'b',
-					{ text: 'c' },
-					'd',
-					{ text: [ 'e', 'f' ] }
-				]
-			} ).render();
+					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="color:red;"></p>' );
+				} );
 
-			expect( el.childNodes ).to.have.length( 5 );
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p>abcdef</p>' );
-		} );
+				it( 'renders as a static value (Array of values)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: [ 'color: red;', 'display: block;' ]
+						}
+					} );
 
-		it( 'activates model bindings – root', () => {
-			const observable = new Model( {
-				foo: 'bar'
-			} );
+					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="color:red;display:block;"></p>' );
+				} );
 
-			const emitter = Object.create( EmitterMixin );
-			const bind = Template.bind( observable, emitter );
-			const el = new Template( {
-				tag: 'div',
-				attributes: {
-					class: bind.to( 'foo' )
-				}
-			} ).render();
+				it( 'renders as a value bound to the model', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: bind.to( 'width', w => `width: ${ w }` )
+						}
+					} );
 
-			expect( el.getAttribute( 'class' ) ).to.equal( 'bar' );
+					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:10px;"></p>' );
 
-			observable.foo = 'baz';
-			expect( el.getAttribute( 'class' ) ).to.equal( 'baz' );
-		} );
+					observable.width = '1em';
 
-		it( 'activates model bindings – children', () => {
-			const observable = new Model( {
-				foo: 'bar'
-			} );
+					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:1em;"></p>' );
+				} );
 
-			const emitter = Object.create( EmitterMixin );
-			const bind = Template.bind( observable, emitter );
-			const el = new Template( {
-				tag: 'div',
-				children: [
-					{
-						tag: 'span',
-						children: [
-							{
-								text: [
-									bind.to( 'foo' ),
-									'static'
-								]
+				it( 'renders as a value bound to the model (Array of bindings)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: [
+								bind.to( 'width', w => `width: ${ w };` ),
+								bind.to( 'backgroundColor', c => `background-color: ${ c };` )
+							]
+						}
+					} );
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:10px;background-color:yellow;"></p>' );
+
+					observable.width = '1em';
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:1em;background-color:yellow;"></p>' );
+				} );
+
+				describe( 'object', () => {
+					it( 'renders with static and bound attributes', () => {
+						setElement( {
+							tag: 'p',
+							attributes: {
+								style: {
+									width: bind.to( 'width' ),
+									height: '10px',
+									backgroundColor: bind.to( 'backgroundColor' )
+								}
 							}
-						]
-					}
-				]
-			} ).render();
+						} );
 
-			expect( el.firstChild.textContent ).to.equal( 'bar static' );
+						expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:10px;height:10px;background-color:yellow;"></p>' );
 
-			observable.foo = 'baz';
-			expect( el.firstChild.textContent ).to.equal( 'baz static' );
-		} );
+						observable.width = '20px';
+						observable.backgroundColor = 'green';
 
-		describe( 'style attribute', () => {
-			let observable, emitter, bind;
+						expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:20px;height:10px;background-color:green;"></p>' );
+					} );
 
-			beforeEach( () => {
-				observable = new Model( {
-					width: '10px',
-					backgroundColor: 'yellow'
-				} );
+					it( 'renders with empty string attributes', () => {
+						setElement( {
+							tag: 'p',
+							attributes: {
+								style: {
+									width: '',
+									backgroundColor: bind.to( 'backgroundColor' )
+								}
+							}
+						} );
 
-				emitter = Object.create( EmitterMixin );
-				bind = Template.bind( observable, emitter );
-			} );
+						expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="background-color:yellow;"></p>' );
 
-			it( 'renders as a static value', () => {
-				setElement( {
-					tag: 'p',
-					attributes: {
-						style: 'color: red'
-					}
-				} );
+						observable.backgroundColor = '';
 
-				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="color:red;"></p>' );
+						expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p></p>' );
+					} );
+
+					it( 'renders with falsy values', () => {
+						setElement( {
+							tag: 'p',
+							attributes: {
+								style: {
+									width: null,
+									height: false,
+									color: undefined
+								}
+							}
+						} );
+
+						expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p></p>' );
+					} );
+				} );
 			} );
+		} );
 
-			it( 'renders as a static value (Array of values)', () => {
-				setElement( {
+		describe( 'children', () => {
+			it( 'creates HTMLElement children', () => {
+				const el = new Template( {
 					tag: 'p',
 					attributes: {
-						style: [ 'color: red;', 'display: block;' ]
-					}
-				} );
+						a: 'A'
+					},
+					children: [
+						{
+							tag: 'b',
+							children: [ 'B' ]
+						},
+						{
+							tag: 'i',
+							children: [
+								'C',
+								{
+									tag: 'b',
+									children: [ 'D' ]
+								}
+							]
+						}
+					]
+				} ).render();
 
-				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="color:red;display:block;"></p>' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p a="A"><b>B</b><i>C<b>D</b></i></p>' );
 			} );
 
-			it( 'renders as a value bound to the model', () => {
-				setElement( {
+			it( 'creates a child Text Node (different syntaxes)', () => {
+				const el = new Template( {
 					tag: 'p',
-					attributes: {
-						style: bind.to( 'width', w => `width: ${ w }` )
-					}
-				} );
+					children: [
+						'foo',
+						{ text: 'bar' }
+					]
+				} ).render();
 
-				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:10px;"></p>' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p>foobar</p>' );
+			} );
 
-				observable.width = '1em';
+			it( 'creates multiple child Text Nodes', () => {
+				const el = new Template( {
+					tag: 'p',
+					children: [
+						'a',
+						'b',
+						{ text: 'c' },
+						'd',
+						{ text: [ 'e', 'f' ] }
+					]
+				} ).render();
 
-				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:1em;"></p>' );
+				expect( el.childNodes ).to.have.length( 5 );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p>abcdef</p>' );
 			} );
 
-			it( 'renders as a value bound to the model (Array of bindings)', () => {
-				setElement( {
-					tag: 'p',
+			it( 'renders view children', () => {
+				const v1 = getView( {
+					tag: 'span',
 					attributes: {
-						style: [
-							bind.to( 'width', w => `width: ${ w };` ),
-							bind.to( 'backgroundColor', c => `background-color: ${ c };` )
+						class: [
+							'v1'
 						]
 					}
 				} );
 
-				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:10px;background-color:yellow;"></p>' );
+				const v2 = getView( {
+					tag: 'span',
+					attributes: {
+						class: [
+							'v2'
+						]
+					}
+				} );
 
-				observable.width = '1em';
+				const tpl = new Template( {
+					tag: 'p',
+					children: [ v1, v2 ]
+				} );
 
-				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:1em;background-color:yellow;"></p>' );
-			} );
+				expect( tpl.children.get( 0 ) ).to.equal( v1 );
+				expect( tpl.children.get( 1 ) ).to.equal( v2 );
 
-			describe( 'object', () => {
-				it( 'renders with static and bound attributes', () => {
-					setElement( {
-						tag: 'p',
-						attributes: {
-							style: {
-								width: bind.to( 'width' ),
-								height: '10px',
-								backgroundColor: bind.to( 'backgroundColor' )
-							}
-						}
-					} );
+				const rendered = tpl.render();
 
-					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:10px;height:10px;background-color:yellow;"></p>' );
+				expect( normalizeHtml( rendered.outerHTML ) ).to.equal( '<p><span class="v1"></span><span class="v2"></span></p>' );
 
-					observable.width = '20px';
-					observable.backgroundColor = 'green';
+				// Make sure the child views will not re–render their elements but
+				// use ones rendered by the template instance above.
+				expect( v1.element ).to.equal( rendered.firstChild );
+				expect( v2.element ).to.equal( rendered.lastChild );
+			} );
+		} );
 
-					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:20px;height:10px;background-color:green;"></p>' );
+		describe( 'bindings', () => {
+			it( 'activates model bindings – root', () => {
+				const observable = new Model( {
+					foo: 'bar'
 				} );
 
-				it( 'renders with empty string attributes', () => {
-					setElement( {
-						tag: 'p',
-						attributes: {
-							style: {
-								width: '',
-								backgroundColor: bind.to( 'backgroundColor' )
-							}
-						}
-					} );
+				const emitter = Object.create( EmitterMixin );
+				const bind = Template.bind( observable, emitter );
+				const el = new Template( {
+					tag: 'div',
+					attributes: {
+						class: bind.to( 'foo' )
+					}
+				} ).render();
 
-					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="background-color:yellow;"></p>' );
+				expect( el.getAttribute( 'class' ) ).to.equal( 'bar' );
 
-					observable.backgroundColor = '';
+				observable.foo = 'baz';
+				expect( el.getAttribute( 'class' ) ).to.equal( 'baz' );
+			} );
 
-					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p></p>' );
+			it( 'activates model bindings – children', () => {
+				const observable = new Model( {
+					foo: 'bar'
 				} );
 
-				it( 'renders with falsy values', () => {
-					setElement( {
-						tag: 'p',
-						attributes: {
-							style: {
-								width: null,
-								height: false,
-								color: undefined
-							}
+				const emitter = Object.create( EmitterMixin );
+				const bind = Template.bind( observable, emitter );
+				const el = new Template( {
+					tag: 'div',
+					children: [
+						{
+							tag: 'span',
+							children: [
+								{
+									text: [
+										bind.to( 'foo' ),
+										'static'
+									]
+								}
+							]
 						}
-					} );
+					]
+				} ).render();
 
-					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p></p>' );
-				} );
+				expect( el.firstChild.textContent ).to.equal( 'bar static' );
+
+				observable.foo = 'baz';
+				expect( el.firstChild.textContent ).to.equal( 'baz static' );
 			} );
 		} );
 	} );
@@ -493,165 +540,189 @@ describe( 'Template', () => {
 			expect( text.textContent ).to.equal( '' );
 		} );
 
-		it( 'applies textContent to a Text Node', () => {
-			new Template( {
-				text: 'abc'
-			} ).apply( text );
-
-			expect( text.textContent ).to.equal( 'abc' );
-		} );
+		describe( 'text', () => {
+			it( 'applies textContent to a Text Node', () => {
+				new Template( {
+					text: 'abc'
+				} ).apply( text );
 
-		it( 'applies attributes to an HTMLElement', () => {
-			new Template( {
-				tag: 'div',
-				attributes: {
-					'class': [ 'a', 'b' ],
-					x: 'bar'
-				}
-			} ).apply( el );
+				expect( text.textContent ).to.equal( 'abc' );
+			} );
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="a b" x="bar"></div>' );
-		} );
+			it( 'applies new textContent to an existing Text Node of an HTMLElement', () => {
+				el.textContent = 'bar';
 
-		it( 'doesn\'t apply new child to an HTMLElement – Text Node', () => {
-			new Template( {
-				tag: 'div',
-				children: [ 'foo' ]
-			} ).apply( el );
+				new Template( {
+					tag: 'div',
+					children: [ 'foo' ]
+				} ).apply( el );
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div></div>' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div>foo</div>' );
+			} );
 		} );
 
-		it( 'doesn\'t apply new child to an HTMLElement – HTMLElement', () => {
-			new Template( {
-				tag: 'div',
-				children: [
-					{
-						tag: 'span'
+		describe( 'attributes', () => {
+			it( 'applies attributes to an HTMLElement', () => {
+				new Template( {
+					tag: 'div',
+					attributes: {
+						'class': [ 'a', 'b' ],
+						x: 'bar'
 					}
-				]
-			} ).apply( el );
+				} ).apply( el );
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div></div>' );
-		} );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="a b" x="bar"></div>' );
+			} );
 
-		it( 'applies new textContent to an existing Text Node of an HTMLElement', () => {
-			el.textContent = 'bar';
+			it( 'applies attributes and TextContent to a DOM tree', () => {
+				el.textContent = 'abc';
+				el.appendChild( document.createElement( 'span' ) );
 
-			new Template( {
-				tag: 'div',
-				children: [ 'foo' ]
-			} ).apply( el );
+				new Template( {
+					tag: 'div',
+					attributes: {
+						'class': 'parent'
+					},
+					children: [
+						'Children:',
+						{
+							tag: 'span',
+							attributes: {
+								class: 'child'
+							}
+						}
+					]
+				} ).apply( el );
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div>foo</div>' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="parent">Children:<span class="child"></span></div>' );
+			} );
 		} );
 
-		it( 'applies attributes and TextContent to a DOM tree', () => {
-			el.textContent = 'abc';
-			el.appendChild( document.createElement( 'span' ) );
+		describe( 'children', () => {
+			it( 'doesn\'t apply new child to an HTMLElement – Text Node', () => {
+				new Template( {
+					tag: 'div',
+					children: [ 'foo' ]
+				} ).apply( el );
 
-			new Template( {
-				tag: 'div',
-				attributes: {
-					'class': 'parent'
-				},
-				children: [
-					'Children:',
-					{
-						tag: 'span',
-						attributes: {
-							class: 'child'
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div></div>' );
+			} );
+
+			it( 'doesn\'t apply new child to an HTMLElement – HTMLElement', () => {
+				new Template( {
+					tag: 'div',
+					children: [
+						{
+							tag: 'span'
 						}
+					]
+				} ).apply( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div></div>' );
+			} );
+
+			it( 'doesn\'t apply new child to an HTMLElement – view', () => {
+				const view = getView( {
+					tag: 'span',
+					attributes: {
+						class: [
+							'v1'
+						]
 					}
-				]
-			} ).apply( el );
+				} );
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="parent">Children:<span class="child"></span></div>' );
-		} );
+				new Template( {
+					tag: 'p',
+					children: [ view ]
+				} ).apply( el );
 
-		it( 'should work for deep DOM structure', () => {
-			const childA = document.createElement( 'a' );
-			const childB = document.createElement( 'b' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div></div>' );
+			} );
 
-			childA.textContent = 'anchor';
-			childB.textContent = 'bold';
+			it( 'should work for deep DOM structure', () => {
+				const childA = document.createElement( 'a' );
+				const childB = document.createElement( 'b' );
 
-			el.appendChild( childA );
-			el.appendChild( childB );
+				childA.textContent = 'anchor';
+				childB.textContent = 'bold';
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div><a>anchor</a><b>bold</b></div>' );
+				el.appendChild( childA );
+				el.appendChild( childB );
 
-			const spy1 = testUtils.sinon.spy();
-			const spy2 = testUtils.sinon.spy();
-			const spy3 = testUtils.sinon.spy();
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div><a>anchor</a><b>bold</b></div>' );
 
-			observable.on( 'ku', spy1 );
-			observable.on( 'kd', spy2 );
-			observable.on( 'mo', spy3 );
+				const spy1 = testUtils.sinon.spy();
+				const spy2 = testUtils.sinon.spy();
+				const spy3 = testUtils.sinon.spy();
 
-			new Template( {
-				tag: 'div',
-				children: [
-					{
-						tag: 'a',
-						on: {
-							keyup: bind.to( 'ku' )
-						},
-						attributes: {
-							class: bind.to( 'foo', val => 'applied-A-' + val ),
-							id: 'applied-A'
-						},
-						children: [ 'Text applied to childA.' ]
-					},
-					{
-						tag: 'b',
-						on: {
-							keydown: bind.to( 'kd' )
+				observable.on( 'ku', spy1 );
+				observable.on( 'kd', spy2 );
+				observable.on( 'mo', spy3 );
+
+				new Template( {
+					tag: 'div',
+					children: [
+						{
+							tag: 'a',
+							on: {
+								keyup: bind.to( 'ku' )
+							},
+							attributes: {
+								class: bind.to( 'foo', val => 'applied-A-' + val ),
+								id: 'applied-A'
+							},
+							children: [ 'Text applied to childA.' ]
 						},
-						attributes: {
-							class: bind.to( 'baz', val => 'applied-B-' + val ),
-							id: 'applied-B'
+						{
+							tag: 'b',
+							on: {
+								keydown: bind.to( 'kd' )
+							},
+							attributes: {
+								class: bind.to( 'baz', val => 'applied-B-' + val ),
+								id: 'applied-B'
+							},
+							children: [ 'Text applied to childB.' ]
 						},
-						children: [ 'Text applied to childB.' ]
+						'Text which is not to be applied because it does NOT exist in original element.'
+					],
+					on: {
+						'mouseover@a': bind.to( 'mo' )
 					},
-					'Text which is not to be applied because it does NOT exist in original element.'
-				],
-				on: {
-					'mouseover@a': bind.to( 'mo' )
-				},
-				attributes: {
-					id: bind.to( 'foo', val => val.toUpperCase() ),
-					class: bind.to( 'baz', val => 'applied-parent-' + val )
-				}
-			} ).apply( el );
+					attributes: {
+						id: bind.to( 'foo', val => val.toUpperCase() ),
+						class: bind.to( 'baz', val => 'applied-parent-' + val )
+					}
+				} ).apply( el );
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="applied-parent-qux" id="BAR">' +
-				'<a class="applied-A-bar" id="applied-A">Text applied to childA.</a>' +
-				'<b class="applied-B-qux" id="applied-B">Text applied to childB.</b>' +
-			'</div>' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="applied-parent-qux" id="BAR">' +
+					'<a class="applied-A-bar" id="applied-A">Text applied to childA.</a>' +
+					'<b class="applied-B-qux" id="applied-B">Text applied to childB.</b>' +
+				'</div>' );
 
-			observable.foo = 'updated';
+				observable.foo = 'updated';
 
-			expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="applied-parent-qux" id="UPDATED">' +
-				'<a class="applied-A-updated" id="applied-A">Text applied to childA.</a>' +
-				'<b class="applied-B-qux" id="applied-B">Text applied to childB.</b>' +
-			'</div>' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="applied-parent-qux" id="UPDATED">' +
+					'<a class="applied-A-updated" id="applied-A">Text applied to childA.</a>' +
+					'<b class="applied-B-qux" id="applied-B">Text applied to childB.</b>' +
+				'</div>' );
 
-			document.body.appendChild( el );
+				document.body.appendChild( el );
 
-			// Test "mouseover@a".
-			dispatchEvent( el, 'mouseover' );
-			dispatchEvent( childA, 'mouseover' );
+				// Test "mouseover@a".
+				dispatchEvent( el, 'mouseover' );
+				dispatchEvent( childA, 'mouseover' );
 
-			// Test "keyup".
-			dispatchEvent( childA, 'keyup' );
+				// Test "keyup".
+				dispatchEvent( childA, 'keyup' );
 
-			// Test "keydown".
-			dispatchEvent( childB, 'keydown' );
+				// Test "keydown".
+				dispatchEvent( childB, 'keydown' );
 
-			sinon.assert.calledOnce( spy1 );
-			sinon.assert.calledOnce( spy2 );
-			sinon.assert.calledOnce( spy3 );
+				sinon.assert.calledOnce( spy1 );
+				sinon.assert.calledOnce( spy2 );
+				sinon.assert.calledOnce( spy3 );
+			} );
 		} );
 	} );
 
@@ -899,12 +970,13 @@ describe( 'Template', () => {
 			} );
 
 			describe( 'to', () => {
-				it( 'returns an object which describes the binding', () => {
+				it( 'returns an instance of TemplateToBinding', () => {
 					const spy = testUtils.sinon.spy();
 					const binding = bind.to( 'foo', spy );
 
+					expect( binding ).to.be.instanceof( TemplateToBinding );
 					expect( spy.called ).to.be.false;
-					expect( binding ).to.have.keys( [ 'type', 'observable', 'eventNameOrFunction', 'emitter', 'attribute', 'callback' ] );
+					expect( binding ).to.have.keys( [ 'observable', 'eventNameOrFunction', 'emitter', 'attribute', 'callback' ] );
 					expect( binding.observable ).to.equal( observable );
 					expect( binding.callback ).to.equal( spy );
 					expect( binding.attribute ).to.equal( 'foo' );
@@ -1124,8 +1196,9 @@ describe( 'Template', () => {
 					const spy = testUtils.sinon.spy();
 					const binding = bind.if( 'foo', 'whenTrue', spy );
 
+					expect( binding ).to.be.instanceof( TemplateIfBinding );
 					expect( spy.called ).to.be.false;
-					expect( binding ).to.have.keys( [ 'type', 'observable', 'emitter', 'attribute', 'callback', 'valueIfTrue' ] );
+					expect( binding ).to.have.keys( [ 'observable', 'emitter', 'attribute', 'callback', 'valueIfTrue' ] );
 					expect( binding.observable ).to.equal( observable );
 					expect( binding.callback ).to.equal( spy );
 					expect( binding.attribute ).to.equal( 'foo' );
@@ -2104,3 +2177,11 @@ function dispatchEvent( el, domEvtName ) {
 		bubbles: true
 	} ) );
 }
+
+function getView( def ) {
+	const view = new View();
+
+	view.template = new Template( def );
+
+	return view;
+}