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

Merge pull request #100 from ckeditor/t/95

t/95: Remove the Controller class.
Piotrek Koszuliński 9 лет назад
Родитель
Сommit
029def7921

+ 7 - 16
packages/ckeditor5-ui/src/componentfactory.js

@@ -45,35 +45,26 @@ export default class ComponentFactory {
 	 * @param {String} name The name of the component.
 	 * @param {Function} ControllerClass The component controller constructor.
 	 * @param {Function} ViewClass The component view constructor.
-	 * @param {ui.Model} model The model of the component.
+	 * @param {Function} [callback] The callback to process the view instance,
+	 * i.e. to set attribute values, create attribute bindings, etc.
 	 */
-	add( name, ControllerClass, ViewClass, model ) {
+	add( name, callback ) {
 		if ( this._components.get( name ) ) {
 			throw new CKEditorError(
 				'componentfactory-item-exists: The item already exists in the component factory.', { name }
 			);
 		}
 
-		this._components.set( name, {
-			ControllerClass,
-			ViewClass,
-			model
-		} );
+		this._components.set( name, callback );
 	}
 
 	/**
-	 * Creates a component instance.
+	 * Creates a component view instance.
 	 *
 	 * @param {String} name The name of the component.
-	 * @returns {ui.Controller} The instantiated component.
+	 * @returns {ui.View} The instantiated component view.
 	 */
 	create( name ) {
-		const component = this._components.get( name );
-
-		const model = component.model;
-		const view = new component.ViewClass( model, this.editor.locale );
-		const controller = new component.ControllerClass( model, view, this.editor );
-
-		return controller;
+		return this._components.get( name )( this.editor.locale );
 	}
 }

+ 0 - 289
packages/ckeditor5-ui/src/controller.js

@@ -1,289 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import Collection from '../utils/collection.js';
-import ControllerCollection from './controllercollection.js';
-import CKEditorError from '../utils/ckeditorerror.js';
-import EmitterMixin from '../utils/emittermixin.js';
-import mix from '../utils/mix.js';
-
-const anon = '$anonymous';
-
-/**
- * Basic Controller class.
- *
- * @memberOf ui
- * @mixes utils.EmitterMixin
- */
-export default class Controller {
-	/**
-	 * Creates an instance of the {@link ui.Controller} class.
-	 *
-	 * @param {ui.Model} [model] Model of this Controller.
-	 * @param {ui.View} [view] View instance of this Controller.
-	 */
-	constructor( model, view ) {
-		/**
-		 * Model of this controller.
-		 *
-		 * @member {ui.Model} ui.Controller#model
-		 */
-		this.model = model || null;
-
-		/**
-		 * Set `true` after {@link #init}.
-		 *
-		 * @member {Boolean} ui.Controller#ready
-		 */
-		this.ready = false;
-
-		/**
-		 * View of this controller.
-		 *
-		 * @member {ui.View} ui.Controller#view
-		 */
-		this.view = view || null;
-
-		/**
-		 * A collection of {@link ControllerCollection} instances containing
-		 * child controllers.
-		 *
-		 * @member {utils.Collection} ui.Controller#collections
-		 */
-		this.collections = new Collection( {
-			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.
-		this.collections.on( 'add', ( evt, collection ) => {
-			// Set the {@link ControllerCollection#parent} to this controller.
-			// It allows the collection to determine the {@link #ready} state of this controller
-			// and accordingly initialize a child controller when added.
-			collection.parent = this;
-
-			this.listenTo( collection, 'add', ( evt, childController, index ) => {
-				// Child view is added to corresponding region in this controller's view
-				// when a new Controller joins the collection.
-				if ( this.ready && childController.view ) {
-					this.view.regions.get( collection.name ).views.add( childController.view, index );
-				}
-			} );
-
-			this.listenTo( collection, 'remove', ( evt, childController ) => {
-				// Child view is removed from corresponding region in this controller's view
-				// when a new Controller is removed from the the collection.
-				if ( this.ready && childController.view ) {
-					this.view.regions.get( collection.name ).views.remove( childController.view );
-				}
-			} );
-		} );
-
-		this.collections.on( 'remove', ( evt, collection ) => {
-			// Release the collection. Once removed from {@link #collections}, it can be
-			// moved to another controller.
-			collection.parent = null;
-
-			this.stopListening( collection );
-		} );
-	}
-
-	/**
-	 * Initializes the controller instance. The process includes:
-	 *
-	 * 1. Initialization of the child {@link #view}.
-	 * 2. Initialization of child controllers in {@link #collections}.
-	 * 3. Setting {@link #ready} flag `true`.
-	 *
-	 * @returns {Promise} A Promise resolved when the initialization process is finished.
-	 */
-	init() {
-		if ( this.ready ) {
-			/**
-			 * This Controller already been initialized.
-			 *
-			 * @error ui-controller-init-reinit
-			 */
-			throw new CKEditorError( 'ui-controller-init-reinit: This Controller already been initialized.' );
-		}
-
-		return Promise.resolve()
-			.then( this._initView.bind( this ) )
-			.then( this._initCollections.bind( this ) )
-			.then( () => {
-				this.ready = true;
-				this.fire( 'ready' );
-			} );
-	}
-
-	/**
-	 * Destroys the controller instance. The process includes:
-	 *
-	 * 1. Destruction of the child {@link #view}.
-	 * 2. Destruction of child controllers in {@link #collections}.
-	 *
-	 * @returns {Promise} A Promise resolved when the destruction process is finished.
-	 */
-	destroy() {
-		let promises = [];
-		let collection, childController;
-
-		for ( collection of this.collections ) {
-			for ( childController of collection ) {
-				promises.push( childController.destroy() );
-			}
-
-			collection.clear();
-		}
-
-		this.collections.clear();
-
-		if ( this.view ) {
-			promises.push( Promise.resolve().then( () => {
-				return this.view.destroy();
-			} ) );
-		}
-
-		promises.push( Promise.resolve().then( () => {
-			this.model = this.ready = this.view = this.collections = null;
-		} ) );
-
-		return Promise.all( promises );
-	}
-
-	/**
-	 * Adds a new collection to {@link ui.Controller#collections}.
-	 *
-	 * @param {String} collectionName Name of the controller collection.
-	 * @returns {ui.ControllerCollection} The new collection instance.
-	 */
-	addCollection( collectionName ) {
-		const collection = new ControllerCollection( collectionName, this.view && this.view.locale );
-
-		this.collections.add( collection );
-
-		return collection;
-	}
-
-	/**
-	 * Adds a child {@link Controller} instance to {@link #collections} at given index.
-	 *
-	 *		// 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( ...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}.
-	 *
-	 * **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( collectionNameOrController, toRemove ) {
-		if ( collectionNameOrController instanceof Controller ) {
-			return this._anonymousCollection.remove( collectionNameOrController );
-		} else {
-			return this.collections.get( collectionNameOrController ).remove( toRemove );
-		}
-	}
-
-	/**
-	 * Initializes the {@link #view} of this controller instance.
-	 *
-	 * @protected
-	 * @returns {Promise} A Promise resolved when initialization process is finished.
-	 */
-	_initView() {
-		let promise = Promise.resolve();
-
-		if ( this.view ) {
-			promise = promise.then( this.view.init.bind( this.view ) );
-		}
-
-		return promise;
-	}
-
-	/**
-	 * Initializes the {@link #collections} of this controller instance.
-	 *
-	 * @protected
-	 * @returns {Promise} A Promise resolved when initialization process is finished.
-	 */
-	_initCollections() {
-		const promises = [];
-		let collection, childController;
-
-		for ( collection of this.collections ) {
-			for ( childController of collection ) {
-				// 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 );
-				}
-
-				promises.push( childController.init() );
-			}
-		}
-
-		return Promise.all( promises );
-	}
-}
-
-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.
- *
- * @event ui.Controller#ready
- */

+ 0 - 307
packages/ckeditor5-ui/src/controllercollection.js

@@ -1,307 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import Collection from '../utils/collection.js';
-import CKEditorError from '../utils/ckeditorerror.js';
-
-/**
- * Manages UI Controllers.
- *
- * @memberOf ui
- * @extends utils.Collection
- */
-export default class ControllerCollection extends Collection {
-	/**
-	 * Creates an instance of the controller collection, initializing it with a name:
-	 *
-	 *		const collection = new ControllerCollection( 'foo' );
-	 *		collection.add( someController );
-	 *
-	 * **Note**: If needed, controller collection can stay in sync with a collection of models to
-	 * manage list–like components. See {@link ui.ControllerCollection#bind} to learn more.
-	 *
-	 * @param {String} name Name of the collection.
-	 * @param {utils.Locale} [locale] The {@link core.editor.Editor#locale editor's locale} instance.
-	 */
-	constructor( name, locale ) {
-		super();
-
-		if ( !name ) {
-			/**
-			 * ControllerCollection must be initialized with a name.
-			 *
-			 * @error ui-controllercollection-no-name
-			 */
-			throw new CKEditorError( 'ui-controllercollection-no-name: ControllerCollection must be initialized with a name.' );
-		}
-
-		/**
-		 * Name of this collection.
-		 *
-		 * @member {String} ui.ControllerCollection#name
-		 */
-		this.name = name;
-
-		/**
-		 * See {@link ui.View#locale}
-		 *
-		 * @readonly
-		 * @member {utils.Locale} ui.ControllerCollection#locale
-		 */
-		this.locale = locale;
-
-		/**
-		 * Parent controller of this collection.
-		 *
-		 * @member {ui.Controller} ui.ControllerCollection#parent
-		 */
-		this.parent = null;
-	}
-
-	/**
-	 * Adds a child controller to the collection. If {@link ui.ControllerCollection#parent} {@link ui.Controller}
-	 * instance is ready, the child view is initialized when added.
-	 *
-	 * @param {ui.Controller} controller A child controller.
-	 * @param {Number} [index] Index at which the child will be added to the collection.
-	 * @returns {Promise} A Promise resolved when the child {@link ui.Controller#init} is done.
-	 */
-	add( controller, index ) {
-		super.add( controller, index );
-
-		// ChildController.init() returns Promise.
-		let promise = Promise.resolve();
-
-		if ( this.parent && this.parent.ready && !controller.ready ) {
-			promise = promise.then( () => {
-				return controller.init();
-			} );
-		}
-
-		return promise;
-	}
-
-	/**
-	 * Synchronizes controller collection with a {@link utils.Collection} of {@link ui.Model} instances.
-	 * The entire collection of controllers reflects changes to the collection of the models, working as a factory.
-	 *
-	 * This method helps bringing complex list–like UI components to life out of the data (like models). The process
-	 * can be automatic:
-	 *
-	 *		// This collection stores models.
-	 *		const models = new Collection( { idProperty: 'label' } );
-	 *
-	 *		// This controller collection will become a factory for the collection of models.
-	 *		const controllers = new ControllerCollection( 'foo', locale );
-	 *
-	 *		// Activate the binding – since now, this controller collection works like a **factory**.
-	 *		controllers.bind( models ).as( FooController, FooView );
-	 *
-	 *		// As new models arrive to the collection, each becomes an instance of FooController (FooView)
-	 *		// in the controller collection.
-	 *		models.add( new Model( { label: 'foo' } ) );
-	 *		models.add( new Model( { label: 'bar' } ) );
-	 *
-	 *		console.log( controllers.length == 2 );
-	 *
-	 *		// Controller collection is updated as the model is removed.
-	 *		models.remove( 0 );
-	 *		console.log( controllers.length == 1 );
-	 *
-	 * or the factory can be driven by a custom callback:
-	 *
-	 *		// This collection stores any kind of data.
-	 *		const data = new Collection();
-	 *
-	 *		// This controller collection will become a custom factory for the data.
-	 *		const controllers = new ControllerCollection( 'foo', locale );
-	 *
-	 *		// Activate the binding – the **factory** is driven by a custom callback.
-	 *		controllers.bind( data ).as( ( item, locale ) => {
-	 *			if ( !item.foo ) {
-	 *				return null;
-	 *			} else if ( item.foo == 'bar' ) {
-	 *				return new BarController( ..., BarView( locale ) );
-	 *			} else {
-	 *				return new DifferentController( ..., DifferentView( locale ) );
-	 *			}
-	 *		} );
-	 *
-	 *		// As new data arrive to the collection, each is handled individually by the callback.
-	 *		// This will produce BarController.
-	 *		data.add( { foo: 'bar' } );
-	 *
-	 *		// And this one will become DifferentController.
-	 *		data.add( { foo: 'baz' } );
-	 *
-	 *		// Also there will be no controller for data without property `foo`.
-	 *		data.add( {} );
-	 *
-	 *		console.log( controllers.length == 2 );
-	 *
-	 *		// Controller collection is updated as the data is removed.
-	 *		data.remove( 0 );
-	 *		console.log( controllers.length == 1 );
-	 *
-	 * @param {utils.Collection.<ui.Model>} collection Models to be synchronized with this controller collection.
-	 * @returns {Function} The `as` function in the `bind( collection ).as( ... )` chain.
-	 * It activates factory using controller and view classes or uses a custom callback to produce
-	 * controller (view) instances.
-	 * @returns {Function} return.ControllerClassOrFunction Specifies the constructor of the controller to be used or
-	 * a custom callback function which produces controllers.
-	 * @returns {Function} [return.ViewClass] Specifies constructor of the view to be used. If not specified,
-	 * `ControllerClassOrFunction` works as as custom callback function.
-	 */
-	bind( collection ) {
-		const controllerMap = new Map();
-		const that = this;
-
-		return {
-			as: ( ControllerClassOrFunction, ViewClass ) => {
-				const handler = ViewClass ?
-						defaultControllerHandler( ControllerClassOrFunction, ViewClass )
-					:
-						genericControllerHandler( ControllerClassOrFunction );
-
-				for ( let item of collection ) {
-					handler.add( item );
-				}
-
-				// Updated controller collection when a new item is added.
-				collection.on( 'add', ( evt, item, index ) => {
-					handler.add( item, index );
-				} );
-
-				// Update controller collection when a item is removed.
-				collection.on( 'remove', ( evt, item ) => {
-					handler.remove( item );
-				} );
-			}
-		};
-
-		function genericControllerHandler( createController ) {
-			return {
-				add( data, index ) {
-					const controller = createController( data, that.locale );
-
-					controllerMap.set( data, controller );
-
-					if ( controller ) {
-						that.add( controller, typeof index == 'number' ? recalculateIndex( index ) : undefined );
-					}
-				},
-
-				remove( data ) {
-					const controller = controllerMap.get( data );
-
-					controllerMap.delete( controller );
-
-					if ( controller ) {
-						that.remove( controller );
-					}
-				}
-			};
-		}
-
-		// Decrement index for each item which has no corresponding controller.
-		function recalculateIndex( index ) {
-			let outputIndex = index;
-
-			for ( let i = 0; i < index; i++ ) {
-				// index -> data -> controller
-				if ( !controllerMap.get( collection.get( i ) ) ) {
-					outputIndex--;
-				}
-			}
-
-			return outputIndex;
-		}
-
-		function defaultControllerHandler( ControllerClass, ViewClass ) {
-			return genericControllerHandler( ( model ) => {
-				return new ControllerClass( model, new ViewClass( that.locale ) );
-			}, controllerMap );
-		}
-	}
-
-	/**
-	 * Delegates selected events coming from within controller models in the collection to desired
-	 * {@link utils.EmitterMixin}. For instance:
-	 *
-	 *		const modelA = new Model();
-	 *		const modelB = new Model();
-	 *		const modelC = new Model();
-	 *
-	 *		const controllers = new ControllerCollection( 'name' );
-	 *
-	 *		controllers.delegate( 'eventX' ).to( modelB );
-	 *		controllers.delegate( 'eventX', 'eventY' ).to( modelC );
-	 *
-	 *		controllers.add( new Controller( modelA, ... ) );
-	 *
-	 * then `eventX` is delegated (fired by) `modelB` and `modelC` along with `customData`:
-	 *
-	 *		modelA.fire( 'eventX', customData );
-	 *
-	 * and `eventY` is delegated (fired by) `modelC` along with `customData`:
-	 *
-	 *		modelA.fire( 'eventY', customData );
-	 *
-	 * See {@link utils.EmitterMixin#delegate}.
-	 *
-	 * @param {...String} events {@link ui.Controller#model} event names to be delegated to another {@link utils.EmitterMixin}.
-	 * @returns {ui.ControllerCollection#delegate#to}
-	 */
-	delegate( ...events ) {
-		if ( !events.length || !isStringArray( events ) ) {
-			/**
-			 * All event names must be strings.
-			 *
-			 * @error ui-controllercollection-delegate-wrong-events
-			 */
-			throw new CKEditorError( 'ui-controllercollection-delegate-wrong-events: All event names must be strings.' );
-		}
-
-		return {
-			/**
-			 * Selects destination for {@link utils.EmitterMixin#delegate} events.
-			 *
-			 * @method ui.ControllerCollection.delegate#to
-			 * @param {utils.EmitterMixin} dest An `EmitterMixin` instance which is the destination for delegated events.
-			 */
-			to: ( dest ) => {
-				// Activate delegating on existing controllers in this collection.
-				for ( let controller of this ) {
-					for ( let evtName of events ) {
-						controller.model.delegate( evtName ).to( dest );
-					}
-				}
-
-				// Activate delegating on future controllers in this collection.
-				this.on( 'add', ( evt, controller ) => {
-					for ( let evtName of events ) {
-						controller.model.delegate( evtName ).to( dest );
-					}
-				} );
-
-				// Deactivate delegating when controller is removed from this collection.
-				this.on( 'remove', ( evt, controller ) => {
-					for ( let evtName of events ) {
-						controller.model.stopDelegating( evtName, dest );
-					}
-				} );
-			}
-		};
-	}
-}
-
-// Check if all entries of the array are of `String` type.
-//
-// @private
-// @param {Array} arr An array to be checked.
-// @returns {Boolean}
-function isStringArray( arr ) {
-	return arr.every( a => typeof a == 'string' );
-}

+ 3 - 5
packages/ckeditor5-ui/src/domemittermixin.js

@@ -48,8 +48,7 @@ const DOMEmitterMixin = extend( {}, EmitterMixin, {
 	 *
 	 * @method ui.DOMEmitterMixin#listenTo
 	 */
-	listenTo() {
-		const args = Array.prototype.slice.call( arguments );
+	listenTo( ...args ) {
 		const emitter = args[ 0 ];
 
 		// Check if emitter is an instance of DOM Node. If so, replace the argument with
@@ -79,8 +78,7 @@ const DOMEmitterMixin = extend( {}, EmitterMixin, {
 	 *
 	 * @method ui.DOMEmitterMixin#stopListening
 	 */
-	stopListening() {
-		const args = Array.prototype.slice.call( arguments );
+	stopListening( ...args ) {
 		const emitter = args[ 0 ];
 
 		// Check if emitter is an instance of DOM Node. If so, replace the argument with corresponding ProxyEmitter.
@@ -275,7 +273,7 @@ extend( ProxyEmitter.prototype, EmitterMixin, {
 //
 // @private
 // @param {Node} node
-// @return {Number} UID for given DOM Node.
+// @return {String} UID for given DOM Node.
 function getNodeUID( node ) {
 	return node[ 'data-ck-expando' ] || ( node[ 'data-ck-expando' ] = uid() );
 }

+ 0 - 77
packages/ckeditor5-ui/src/region.js

@@ -1,77 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import Collection from '../utils/collection.js';
-
-/**
- * Basic Region class.
- *
- * @memberOf ui
- */
-export default class Region {
-	/**
-	 * Creates an instance of the {@link ui.Region} class.
-	 *
-	 * @param {String} name The name of the Region.
-	 */
-	constructor( name ) {
-		/**
-		 * The name of the region.
-		 *
-		 * @member {String} ui.Region#name
-		 */
-		this.name = name;
-
-		/**
-		 * Views which belong to the region.
-		 *
-		 * @member {utils.Collection} ui.Region#views
-		 */
-		this.views = new Collection();
-
-		/**
-		 * Element of this region (see {@link #init}).
-		 *
-		 * @member {HTMLElement} ui.Region#element
-		 */
-		this.element = null;
-	}
-
-	/**
-	 * Initializes region instance with an element. Usually it comes from {@link View#init}.
-	 *
-	 * @param {HTMLElement} regionElement Element of this region.
-	 */
-	init( regionElement ) {
-		this.element = regionElement;
-
-		if ( regionElement ) {
-			this.views.on( 'add', ( evt, childView, index ) => {
-				regionElement.insertBefore( childView.element, regionElement.childNodes[ index ] );
-			} );
-
-			this.views.on( 'remove', ( evt, childView ) => {
-				childView.element.remove();
-			} );
-		}
-	}
-
-	/**
-	 * Destroys region instance.
-	 */
-	destroy() {
-		if ( this.element ) {
-			for ( let view of this.views ) {
-				view.element.remove();
-				this.views.remove( view );
-			}
-		}
-
-		// Drop the reference to HTMLElement but don't remove it from DOM.
-		// Element comes as a parameter and it could be a part of the View.
-		// Then it's up to the View what to do with it when the View is destroyed.
-		this.element = this.views = null;
-	}
-}

+ 45 - 12
packages/ckeditor5-ui/src/template.js

@@ -10,6 +10,7 @@ import mix from '../utils/mix.js';
 import EmitterMixin from '../utils/emittermixin.js';
 import Collection from '../utils/collection.js';
 import View from './view.js';
+import ViewCollection from './viewcollection.js';
 import cloneDeepWith from '../utils/lib/lodash/cloneDeepWith.js';
 import isObject from '../utils/lib/lodash/isObject.js';
 
@@ -354,11 +355,11 @@ export default class Template {
 		if ( intoFragment ) {
 			const docFragment = document.createDocumentFragment();
 
-			this._renderElementChildren( docFragment );
+			this._renderElementChildren( el, docFragment );
 
 			el.appendChild( docFragment );
 		} else {
-			this._renderElementChildren( el, !!applyElement );
+			this._renderElementChildren( el, el, !!applyElement );
 		}
 
 		// Setup DOM bindings event listeners.
@@ -505,22 +506,32 @@ export default class Template {
 	 * Recursively renders `HTMLElement` children from {@link ui.Template#children}.
 	 *
 	 * @protected
-	 * @param {HTMLElement} elOrDocFragment `HTMLElement` or `DocumentFragment` which is being rendered.
+	 * @param {HTMLElement} element The element which is being rendered.
+	 * @param {HTMLElement|DocumentFragment} container `HTMLElement` or `DocumentFragment`
+	 * into which children are being rendered. If `shouldApply == true`, then `container === element`.
 	 * @param {Boolean} shouldApply Traverse existing DOM structure only, don't modify DOM.
 	 */
-	_renderElementChildren( elOrDocFragment, shouldApply ) {
+	_renderElementChildren( element, container, shouldApply ) {
 		let childIndex = 0;
 
 		for ( let child of this.children ) {
-			if ( isView( child ) ) {
+			if ( isViewCollection( child ) ) {
 				if ( !shouldApply ) {
-					elOrDocFragment.appendChild( child.element );
+					child.setParent( element );
+
+					for ( let view of child ) {
+						container.appendChild( view.element );
+					}
+				}
+			} else if ( isView( child ) ) {
+				if ( !shouldApply ) {
+					container.appendChild( child.element );
 				}
 			} else {
 				if ( shouldApply ) {
-					child._renderNode( elOrDocFragment.childNodes[ childIndex++ ] );
+					child._renderNode( container.childNodes[ childIndex++ ] );
 				} else {
-					elOrDocFragment.appendChild( child.render() );
+					container.appendChild( child.render() );
 				}
 			}
 		}
@@ -803,7 +814,7 @@ function getTextUpdater( node ) {
 // @param {String} attrName Name of the attribute to be modified.
 // @param {String} [ns=null] Namespace to use.
 // @returns {Object}
-function getAttributeUpdater( el, attrName, ns = null ) {
+function getAttributeUpdater( el, attrName, ns ) {
 	return {
 		set( value ) {
 			el.setAttributeNS( ns, attrName, value );
@@ -848,7 +859,7 @@ function clone( def ) {
 		// 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 ) ) ) {
+		if ( value && ( value instanceof TemplateBinding || isView( value ) || isViewCollection( value ) ) ) {
 			return value;
 		}
 	} );
@@ -888,8 +899,16 @@ function normalize( def ) {
 		const children = new Collection();
 
 		if ( def.children ) {
-			for ( let child of def.children ) {
-				children.add( isView( child ) ? child : new Template( child ) );
+			if ( isViewCollection( def.children ) ) {
+				children.add( def.children );
+			} else {
+				for ( let child of def.children ) {
+					if ( isView( child ) ) {
+						children.add( child );
+					} else {
+						children.add( new Template( child ) );
+					}
+				}
 			}
 		}
 
@@ -1116,6 +1135,14 @@ function isView( item ) {
 	return item instanceof View;
 }
 
+// Checks if the item is an instance of {@link ui.ViewCollection}
+//
+// @private
+// @param {*} value Value to be checked.
+function isViewCollection( item ) {
+	return item instanceof ViewCollection;
+}
+
 /**
  * A definition of {@link ui.Template}.
  * See: {@link ui.TemplateValueSchema}.
@@ -1149,6 +1176,12 @@ function isView( item ) {
  *			}
  *		} );
  *
+ *		// An entire view collection can be used as a child in the definition.
+ *		new Template( {
+ *			tag: 'p',
+ *			children: <{@link ui.ViewCollection} instance>
+ *		} );
+ *
  * @typedef ui.TemplateDefinition
  * @type Object
  * @property {String} tag

+ 155 - 129
packages/ckeditor5-ui/src/view.js

@@ -3,12 +3,12 @@
  * For licensing, see LICENSE.md.
  */
 
-import Collection from '../utils/collection.js';
-import Region from './region.js';
-import Template from './template.js';
 import CKEditorError from '../utils/ckeditorerror.js';
+import ViewCollection from './viewcollection.js';
+import Template from './template.js';
 import DOMEmitterMixin from './domemittermixin.js';
 import ObservableMixin from '../utils/observablemixin.js';
+import Collection from '../utils/collection.js';
 import mix from '../utils/mix.js';
 
 /**
@@ -22,10 +22,41 @@ export default class View {
 	/**
 	 * Creates an instance of the {@link ui.View} class.
 	 *
+	 *		class SampleView extends View {
+	 *			constructor( locale ) {
+	 *				super( locale );
+	 *
+	 *				this.template = new Template( {
+	 *					tag: 'p',
+	 *					children: [
+	 *						'Hello',
+	 *						{
+	 *							tag: 'b',
+	 *							children: [
+	 *								'world!'
+	 *							]
+	 *						}
+	 *					],
+	 *					attributes: {
+	 *						class: 'foo'
+	 *					}
+	 *				} );
+	 *			}
+	 *		}
+	 *
+	 *		const view = new SampleView( locale )
+	 *
+	 *		view.init().then( () => {
+	 *			// Will append <p class="foo">Hello<b>world</b></p>
+	 *			document.body.appendChild( view.element );
+	 *		} );
+	 *
 	 * @param {utils.Locale} [locale] The {@link core.editor.Editor#locale editor's locale} instance.
 	 */
 	constructor( locale ) {
 		/**
+		 * A set of tools to localize the user interface. See {@link core.editor.Editor#locale}.
+		 *
 		 * @readonly
 		 * @member {utils.Locale} ui.View#locale
 		 */
@@ -34,7 +65,7 @@ export default class View {
 		/**
 		 * Shorthand for {@link utils.Locale#t}.
 		 *
-		 * Note: If locale instance hasn't been passed to the view this method may not be available.
+		 * Note: If locale instance hasn't been 	passed to the view this method may not be available.
 		 *
 		 * @see utils.Locale#t
 		 * @method ui.View#t
@@ -42,27 +73,42 @@ export default class View {
 		this.t = locale && locale.t;
 
 		/**
-		 * Regions of this view. See {@link ui.View#register}.
+		 * Set `true` after {@link ui.View#init}, which can be asynchronous.
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {Boolean} ui.View#ready
+		 */
+		this.set( 'ready', false );
+
+		/**
+		 * Collections registered with {@link ui.View#createCollection}.
 		 *
-		 * @member {utils.Collection} ui.View#regions
+		 * @protected
+		 * @member {Set.<ui.ViewCollection>} ui.view#_viewCollections
 		 */
-		this.regions = new Collection( {
-			idProperty: 'name'
+		this._viewCollections = new Collection();
+
+		// Let the new collection determine the {@link ui.View#ready} state of this view and,
+		// accordingly, initialize (or not) children views as they are added in the future.
+		this._viewCollections.on( 'add', ( evt, collection ) => {
+			collection.locale = locale;
 		} );
 
 		/**
-		 * Template of this view.
+		 * A collection of view instances, which have been added directly
+		 * into the {@link ui.View.template#children}.
 		 *
-		 * @member {ui.Template} ui.View#template
+		 * @protected
+		 * @member {ui.ViewCollection} ui.view#_unboundChildren
 		 */
+		this._unboundChildren = this.createCollection();
 
 		/**
-		 * Region selectors of this view. See {@link ui.View#register}.
+		 * Template of this view.
 		 *
-		 * @private
-		 * @member {Object} ui.View#_regionSelectors
+		 * @member {ui.Template} ui.View#template
 		 */
-		this._regionSelectors = {};
 
 		/**
 		 * Element of this view.
@@ -122,156 +168,136 @@ export default class View {
 	}
 
 	/**
-	 * Initializes the view.
+	 * Creates a new collection of views, which can be used in this view instance
+	 * i.e. as a member of {@link ui.TemplateDefinition#children}.
+	 *
+	 *		class SampleView extends View {
+	 *			constructor( locale ) {
+	 *				super( locale );
+	 *
+	 *				this.items = this.createCollection();
+ 	 *
+	 *				this.template = new Template( {
+	 *					tag: 'p',
+	 *
+	 *					// `items` collection will render here.
+	 *					children: this.items
+	 *				} );
+	 *			}
+	 *		}
+	 *
+	 *		const view = new SampleView( locale )
+	 *		const anotherView = new AnotherSampleView( locale )
 	 *
-	 * Note: {@link ui.Controller} supports if a promise is returned by this method,
-	 * what means that view initialization may be asynchronous.
+	 *		view.init().then( () => {
+	 *			// Will append <p></p>
+	 *			document.body.appendChild( view.element );
+	 *
+	 *			// `anotherView` becomes a child of the view, which is reflected in DOM
+	 *			// <p><anotherView#element></p>
+	 *			view.items.add( anotherView );
+	 *		} );
+	 *
+	 * @returns {ui.ViewCollection} A new collection of view instances.
 	 */
-	init() {
-		this._initRegions();
+	createCollection() {
+		const collection = new ViewCollection();
+
+		this._viewCollections.add( collection );
+
+		return collection;
 	}
 
 	/**
-	 * Registers a region in {@link ui.View#regions}.
+	 * Registers a new child view under this view instance. Once registered, a child
+	 * view is managed by its parent, including initialization ({@link ui.view#init})
+	 * and destruction ({@link ui.view#destroy}).
+	 *
+	 *		class SampleView extends View {
+	 *			constructor( locale ) {
+	 *				super( locale );
+	 *
+	 *				this.childView = new SomeChildView( locale );
 	 *
-	 *		let view = new View();
+	 *				// Register a new child view.
+	 *				this.addChild( this.childView );
 	 *
-	 *		// region.name == "foo", region.element == view.element.firstChild
-	 *		view.register( 'foo', el => el.firstChild );
+	 *				this.template = new Template( {
+	 *					tag: 'p',
 	 *
-	 *		// region.name == "bar", region.element == view.element.querySelector( 'span' )
-	 *		view.register( new Region( 'bar' ), 'span' );
+	 *					children: [
+	 *						{ tag: 'b' },
+	 *						// This is where the `childView` will render.
+	 *						this.childView
+	 *					]
+	 *				} );
+	 *			}
+	 *		}
 	 *
-	 *		// region.name == "bar", region.element == view.element.querySelector( '#div#id' )
-	 *		view.register( 'bar', 'div#id', true );
+	 *		const view = new SampleView( locale )
 	 *
-	 *		// region.name == "baz", region.element == null
-	 *		view.register( 'baz', true );
+	 *		view.init().then( () => {
+	 *			// Will append <p><b></b><childView#element></p>
+	 *			document.body.appendChild( view.element );
+	 *		} );
 	 *
-	 * @param {String|Region} stringOrRegion The name or an instance of the Region
-	 * to be registered. If `String`, the region will be created on the fly.
-	 * @param {String|Function|true} regionSelector The selector to retrieve region's element
-	 * in DOM when the region instance is initialized (see {@link Region#init}, {@link ui.View#init}).
-	 * @param {Boolean} [override] When set `true` it will allow overriding of registered regions.
+	 * @param {...ui.View} children Children views to be registered.
 	 */
-	register( ...args ) {
-		let region, regionName;
-
-		if ( typeof args[ 0 ] === 'string' ) {
-			regionName = args[ 0 ];
-			region = this.regions.get( regionName ) || new Region( regionName );
-		} else if ( args[ 0 ] instanceof Region ) {
-			regionName = args[ 0 ].name;
-			region = args[ 0 ];
-		} else {
-			/**
-			 * A name of the region or an instance of Region is required.
-			 *
-			 * @error ui-view-register-wrongtype
-			 */
-			throw new CKEditorError( 'ui-view-register-wrongtype' );
+	addChild( ...children ) {
+		for ( let child of children ) {
+			this._unboundChildren.add( child );
 		}
+	}
 
-		const regionSelector = args[ 1 ];
-
-		if ( !regionSelector || !isValidRegionSelector( regionSelector ) ) {
+	/**
+	 * Initializes the view and child views located in {@link ui.View#_viewCollections}.
+	 *
+	 * @returns {Promise} A Promise resolved when the initialization process is finished.
+	 */
+	init() {
+		if ( this.ready ) {
 			/**
-			 * The selector must be String, Function or `true`.
+			 * This View has already been initialized.
 			 *
-			 * @error ui-view-register-badselector
+			 * @error ui-view-init-reinit
 			 */
-			throw new CKEditorError( 'ui-view-register-badselector' );
+			throw new CKEditorError( 'ui-view-init-reinit: This View has already been initialized.' );
 		}
 
-		const registered = this.regions.get( regionName );
-
-		if ( !registered ) {
-			this.regions.add( region );
-		} else {
-			if ( registered !== region ) {
-				if ( !args[ 2 ] ) {
-					/**
-					 * Overriding is possible only when `override` flag is set.
-					 *
-					 * @error ui-view-register-override
-					 */
-					throw new CKEditorError( 'ui-view-register-override' );
-				}
-
-				this.regions.remove( registered );
-				this.regions.add( region );
-			}
-		}
-
-		this._regionSelectors[ regionName ] = regionSelector;
+		return Promise.resolve()
+			// Initialize collections in #_viewCollections.
+			.then( () => {
+				return Promise.all( this._viewCollections.map( c => c.init() ) );
+			} )
+			// Spread the word that this view is ready!
+			.then( () => {
+				this.ready = true;
+			} );
 	}
 
 	/**
-	 * Destroys the view instance. The process includes:
+	 * Destroys the view instance and child views located in {@link ui.View#_viewCollections}.
 	 *
-	 * 1. Removal of child views from {@link ui.View#regions}.
-	 * 2. Destruction of the {@link ui.View#regions}.
-	 * 3. Removal of {@link #_el} from DOM.
+	 * @returns {Promise} A Promise resolved when the destruction process is finished.
 	 */
 	destroy() {
-		let childView;
-
 		this.stopListening();
 
-		for ( let region of this.regions ) {
-			while ( ( childView = region.views.get( 0 ) ) ) {
-				region.views.remove( childView );
-			}
+		const promises = this._viewCollections.map( c => c.destroy() );
 
-			this.regions.remove( region ).destroy();
-		}
+		this._unboundChildren.clear();
+		this._viewCollections.clear();
 
-		if ( this.template ) {
+		if ( this.element ) {
 			this.element.remove();
 		}
 
-		this.model = this.regions = this.template = this.locale = this.t = null;
-		this._regionSelectors = this._element = null;
-	}
-
-	/**
-	 * Initializes {@link ui.View#regions} of this view by passing a DOM element
-	 * generated from {@link ui.View#_regionSelectors} into {@link Region#init}.
-	 *
-	 * @protected
-	 */
-	_initRegions() {
-		let region, regionEl, regionSelector;
-
-		for ( region of this.regions ) {
-			regionSelector = this._regionSelectors[ region.name ];
-
-			if ( typeof regionSelector == 'string' ) {
-				regionEl = this.element.querySelector( regionSelector );
-			} else if ( typeof regionSelector == 'function' ) {
-				regionEl = regionSelector( this.element );
-			} else {
-				regionEl = null;
-			}
+		this.element = this.template = this.locale = this.t =
+			this._viewCollections = this._unboundChildren = null;
 
-			region.init( regionEl );
-		}
+		return Promise.all( promises );
 	}
 }
 
 mix( View, DOMEmitterMixin );
 mix( View, ObservableMixin );
-
-const validSelectorTypes = new Set( [ 'string', 'boolean', 'function' ] );
-
-/**
- * Check whether region selector is valid.
- *
- * @ignore
- * @private
- * @param {*} selector Selector to be checked.
- * @returns {Boolean}
- */
-function isValidRegionSelector( selector ) {
-	return validSelectorTypes.has( typeof selector ) && selector !== false;
-}

+ 351 - 0
packages/ckeditor5-ui/src/viewcollection.js

@@ -0,0 +1,351 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import CKEditorError from '../utils/ckeditorerror.js';
+import ObservableMixin from '../utils/observablemixin.js';
+import Collection from '../utils/collection.js';
+import mix from '../utils/mix.js';
+
+/**
+ * Collects {@link ui.View} instances.
+ *
+ * @memberOf ui
+ * @extends utils.Collection
+ */
+export default class ViewCollection extends Collection {
+	/**
+	 * Creates a new {@link ui.ViewCollection} instance.
+	 *
+	 * @param {utils.Locale} [locale] The {@link core.editor.Editor#locale editor's locale} instance.
+	 */
+	constructor( locale ) {
+		super();
+
+		// Handle {@link ui.View#element} in DOM when a new view is added to the collection.
+		this.on( 'add', ( evt, view, index ) => {
+			if ( this.ready && view.element && this._parentElement ) {
+				this._parentElement.insertBefore( view.element, this._parentElement.children[ index ] );
+			}
+		} );
+
+		// Handle {@link ui.View#element} in DOM when a view is removed from the collection.
+		this.on( 'remove', ( evt, view ) => {
+			if ( this.ready && view.element && this._parentElement ) {
+				view.element.remove();
+			}
+		} );
+
+		/**
+		 * The {@link core.editor.Editor#locale editor's locale} instance.
+		 *
+		 * @member {utils.Locale} ui.ViewCollection#locale
+		 */
+		this.locale = locale;
+
+		/**
+		 * Set `true` once parent {@link ui.View#ready} is true, which means
+		 * that all the views in the collection are also ready (which can be asynchronous).
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {Boolean} ui.ViewCollection#ready
+		 */
+		this.set( 'ready', false );
+
+		/**
+		 * A parent element within which child views are rendered and managed in DOM.
+		 *
+		 * @protected
+		 * @member {HTMLElement} ui.ViewCollection#_parentElement
+		 */
+		this._parentElement = null;
+
+		/**
+		 * A helper mapping between bound collection items passed to {@link ui.ViewCollection#bindTo}
+		 * and view instances. Speeds up the view management.
+		 *
+		 * @protected
+		 * @member {HTMLElement} ui.ViewCollection#_boundItemsToViewsMap
+		 */
+		this._boundItemsToViewsMap = new Map();
+	}
+
+	/**
+	 * Initializes child views by injecting {@link ui.View#element} into DOM
+	 * and calling {@link ui.View#init}.
+	 *
+	 * @returns {Promise} A Promise resolved when the initialization process is finished.
+	 */
+	init() {
+		if ( this.ready ) {
+			/**
+			 * This ViewCollection has already been initialized.
+			 *
+			 * @error ui-viewcollection-init-reinit
+			 */
+			throw new CKEditorError( 'ui-viewcollection-init-reinit: This ViewCollection has already been initialized.' );
+		}
+
+		const promises = [];
+
+		for ( let view of this ) {
+			// Do not render unbound children. They're already in DOM by explicit declaration
+			// in Template definition.
+			if ( this._parentElement && view.element ) {
+				this._parentElement.appendChild( view.element );
+			}
+
+			promises.push( view.init() );
+		}
+
+		return Promise.all( promises ).then( () => {
+			this.ready = true;
+		} );
+	}
+
+	/**
+	 * Destroys the view collection along with child views.
+	 *
+	 * @returns {Promise} A Promise resolved when the destruction process is finished.
+	 */
+	destroy() {
+		let promises = [];
+
+		for ( let view of this ) {
+			promises.push( view.destroy() );
+		}
+
+		return Promise.all( promises );
+	}
+
+	/**
+	 * Adds a child view to the collection. If {@link ui.ViewCollection#ready}, the child view
+	 * is also initialized when added.
+	 *
+	 * @param {ui.View} view A child view.
+	 * @param {Number} [index] Index at which the child will be added to the collection.
+	 * @returns {Promise} A Promise resolved when the child {@link ui.View#init} is done.
+	 */
+	add( view, index ) {
+		super.add( view, index );
+
+		// {@link ui.View#init} returns `Promise`.
+		let promise = Promise.resolve();
+
+		if ( this.ready && !view.ready ) {
+			promise = promise.then( () => {
+				return view.init();
+			} );
+		}
+
+		return promise;
+	}
+
+	/**
+	 * Sets {@link ui.ViewCollection#parent} of this collection.
+	 *
+	 * @param {HTMLElement} element A new parent.
+	 */
+	setParent( elementOrDocFragment ) {
+		this._parentElement = elementOrDocFragment;
+	}
+
+	/**
+	 * Binds a view collection to {@link utils.Collection} of items to create a factory of
+	 * view instances.
+	 *
+	 * The process can be automatic:
+	 *
+	 *		// This collection stores items.
+	 *		const items = new Collection( { idProperty: 'label' } );
+	 *
+	 *		// This view collection will become a factory out of the collection of items.
+	 *		const views = new ViewCollection( locale );
+	 *
+	 *		// Activate the binding – since now, this view collection works like a **factory**.
+	 *		// Each new item is passed to the FooView constructor like new FooView( locale, item ).
+	 *		views.bindTo( items ).as( FooView );
+	 *
+	 *		// As new items arrive to the collection, each becomes an instance of FooView
+	 *		// in the view collection.
+	 *		items.add( new Model( { label: 'foo' } ) );
+	 *		items.add( new Model( { label: 'bar' } ) );
+	 *
+	 *		console.log( views.length == 2 );
+	 *
+	 *		// View collection is updated as the model is removed.
+	 *		items.remove( 0 );
+	 *		console.log( views.length == 1 );
+	 *
+	 * or the factory can be driven by a custom callback:
+	 *
+	 *		// This collection stores any kind of data.
+	 *		const data = new Collection();
+	 *
+	 *		// This view collection will become a custom factory for the data.
+	 *		const views = new ViewCollection( locale );
+	 *
+	 *		// Activate the binding – the **factory** is driven by a custom callback.
+	 *		views.bind( data ).as( item => {
+	 *			if ( !item.foo ) {
+	 *				return null;
+	 *			} else if ( item.foo == 'bar' ) {
+	 *				return new BarView();
+	 *			} else {
+	 *				return new DifferentView();
+	 *			}
+	 *		} );
+	 *
+	 *		// As new data arrive to the collection, each is handled individually by the callback.
+	 *		// This will produce BarView.
+	 *		data.add( { foo: 'bar' } );
+	 *
+	 *		// And this one will become DifferentView.
+	 *		data.add( { foo: 'baz' } );
+	 *
+	 *		// Also there will be no view for data lacking the `foo` property.
+	 *		data.add( {} );
+	 *
+	 *		console.log( controllers.length == 2 );
+	 *
+	 *		// View collection is also updated as the data is removed.
+	 *		data.remove( 0 );
+	 *		console.log( controllers.length == 1 );
+	 *
+	 * @param {utils.Collection} collection A collection to be bound.
+	 * @returns {ui.ViewCollection.bindTo#as}
+	 */
+	bindTo( collection ) {
+		return {
+			/**
+			 * Determines the output view of the binding.
+			 *
+			 * @method ui.ViewCollection.bindTo#as
+			 * @param {Function|ui.View} CallbackOrViewClass Specifies the constructor of the view to be used or
+			 * a custom callback function which produces views.
+			 */
+			as: ( CallbackOrViewClass ) => {
+				let createView;
+
+				if ( CallbackOrViewClass.prototype ) {
+					createView = ( item ) => {
+						const viewInstance = new CallbackOrViewClass( this.locale, item );
+
+						this._boundItemsToViewsMap.set( item, viewInstance );
+
+						return viewInstance;
+					};
+				} else {
+					createView = ( item ) => {
+						const viewInstance = CallbackOrViewClass( item );
+
+						this._boundItemsToViewsMap.set( item, viewInstance );
+
+						return viewInstance;
+					};
+				}
+
+				// Load the initial content of the collection.
+				for ( let item of collection ) {
+					this.add( createView( item ) );
+				}
+
+				// Synchronize views as new items are added to the collection.
+				this.listenTo( collection, 'add', ( evt, item, index ) => {
+					this.add( createView( item ), index );
+				} );
+
+				// Synchronize views as items are removed from the collection.
+				this.listenTo( collection, 'remove', ( evt, item ) => {
+					this.remove( this._boundItemsToViewsMap.get( item ) );
+
+					this._boundItemsToViewsMap.delete( item );
+				} );
+			}
+		};
+	}
+
+	/**
+	 * Delegates selected events coming from within the collection to desired {@link utils.Emitter}.
+	 *
+	 * For instance:
+	 *
+	 *		const viewA = new View();
+	 *		const viewB = new View();
+	 *		const viewC = new View();
+	 *
+	 *		const views = parentView.createCollection();
+	 *
+	 *		views.delegate( 'eventX' ).to( viewB );
+	 *		views.delegate( 'eventX', 'eventY' ).to( viewC );
+	 *
+	 *		views.add( viewA );
+	 *
+	 * then `eventX` is delegated (fired by) `viewB` and `viewC` along with `customData`:
+	 *
+	 *		viewA.fire( 'eventX', customData );
+	 *
+	 * and `eventY` is delegated (fired by) `viewC` along with `customData`:
+	 *
+	 *		viewA.fire( 'eventY', customData );
+	 *
+	 * See {@link utils.EmitterMixin#delegate}.
+	 *
+	 * @param {...String} events {@link ui.View} event names to be delegated to another {@link utils.Emitter}.
+	 * @returns {ui.ViewCollection#delegate#to}
+	 */
+	delegate( ...events ) {
+		if ( !events.length || !isStringArray( events ) ) {
+			/**
+			 * All event names must be strings.
+			 *
+			 * @error ui-viewcollection-delegate-wrong-events
+			 */
+			throw new CKEditorError( 'ui-viewcollection-delegate-wrong-events: All event names must be strings.' );
+		}
+
+		return {
+			/**
+			 * Selects destination for {@link utils.EmitterMixin#delegate} events.
+			 *
+			 * @method ui.ViewCollection.delegate#to
+			 * @param {utils.EmitterMixin} dest An `EmitterMixin` instance which is the destination for delegated events.
+			 */
+			to: ( dest ) => {
+				// Activate delegating on existing views in this collection.
+				for ( let view of this ) {
+					for ( let evtName of events ) {
+						view.delegate( evtName ).to( dest );
+					}
+				}
+
+				// Activate delegating on future views in this collection.
+				this.on( 'add', ( evt, view ) => {
+					for ( let evtName of events ) {
+						view.delegate( evtName ).to( dest );
+					}
+				} );
+
+				// Deactivate delegating when view is removed from this collection.
+				this.on( 'remove', ( evt, view ) => {
+					for ( let evtName of events ) {
+						view.stopDelegating( evtName, dest );
+					}
+				} );
+			}
+		};
+	}
+}
+
+mix( Collection, ObservableMixin );
+
+// Check if all entries of the array are of `String` type.
+//
+// @private
+// @param {Array} arr An array to be checked.
+// @returns {Boolean}
+function isStringArray( arr ) {
+	return arr.every( a => typeof a == 'string' );
+}

+ 13 - 16
packages/ckeditor5-ui/tests/_utils-tests/utils.js

@@ -8,43 +8,40 @@
 import testUtils from 'tests/ui/_utils/utils.js';
 
 describe( 'utils', () => {
-	describe( 'createTestUIController', () => {
+	describe( 'createTestUIView', () => {
 		it( 'returns a promise', () => {
-			expect( testUtils.createTestUIController() ).to.be.instanceof( Promise );
+			expect( testUtils.createTestUIView() ).to.be.instanceof( Promise );
 		} );
 
-		describe( 'controller instance', () => {
+		describe( 'view instance', () => {
 			it( 'comes with a view', () => {
-				const promise = testUtils.createTestUIController();
+				const promise = testUtils.createTestUIView();
 
-				return promise.then( controller => {
-					expect( controller.view.element ).to.equal( document.body );
+				return promise.then( view => {
+					expect( view.element ).to.equal( document.body );
 				} );
 			} );
 
 			it( 'creates collections and regions', () => {
-				const promise = testUtils.createTestUIController( {
+				const promise = testUtils.createTestUIView( {
 					foo: el => el.firstChild,
 					bar: el => el.lastChild,
 				} );
 
-				promise.then( controller => {
-					expect( controller.collections.get( 'foo' ) ).to.be.not.undefined;
-					expect( controller.collections.get( 'bar' ) ).to.be.not.undefined;
-
-					expect( controller.view.regions.get( 'foo' ).element ).to.equal( document.body.firstChild );
-					expect( controller.view.regions.get( 'bar' ).element ).to.equal( document.body.lastChild );
+				return promise.then( view => {
+					expect( view.foo._parentElement ).to.equal( document.body.firstChild );
+					expect( view.bar._parentElement ).to.equal( document.body.lastChild );
 				} );
 			} );
 
 			it( 'is ready', () => {
-				const promise = testUtils.createTestUIController( {
+				const promise = testUtils.createTestUIView( {
 					foo: el => el.firstChild,
 					bar: el => el.lastChild,
 				} );
 
-				promise.then( controller => {
-					expect( controller.ready ).to.be.true;
+				return promise.then( view => {
+					expect( view.ready ).to.be.true;
 				} );
 			} );
 		} );

+ 16 - 13
packages/ckeditor5-ui/tests/_utils/utils.js

@@ -6,7 +6,6 @@
 /* globals document */
 
 import View from 'ckeditor5/ui/view.js';
-import Controller from 'ckeditor5/ui/controller.js';
 
 /**
  * Test utils for CKEditor UI.
@@ -21,35 +20,39 @@ const utils = {
 	 * Usage:
 	 *
 	 *		// Get the controller.
-	 *		const controller = testUtils.createTestUIController();
+	 *		const controller = testUtils.createTestUIView();
 	 *
 	 *		// Then use it to organize and initialize children.
 	 *		controller.add( 'some-collection', childControllerInstance );
 	 *
-	 * @param {Object} regions An object literal with `regionName: DOM Selector pairs`.
+	 * @param {Object} regions An object literal with `regionName: [DOM Selector|callback]` pairs.
 	 * See {@link ui.View#register}.
 	 */
-	createTestUIController( regions ) {
+	createTestUIView( regions ) {
 		const TestUIView = class extends View {
 			constructor() {
 				super();
 
 				this.element = document.body;
 
-				for ( let r in regions ) {
-					this.register( r, regions[ r ] );
+				for ( let name in regions ) {
+					const regionCollection = this[ name ] = this.createCollection();
+					const callbackOrSelector = regions[ name ];
+
+					regionCollection.setParent(
+						typeof callbackOrSelector == 'string' ?
+								document.querySelector( callbackOrSelector )
+							:
+								callbackOrSelector( this.element )
+					);
 				}
 			}
 		};
 
-		const controller = new Controller( null, new TestUIView() );
-
-		for ( let r in regions ) {
-			controller.addCollection( r );
-		}
+		const view = new TestUIView();
 
-		return controller.init().then( () => {
-			return controller;
+		return view.init().then( () => {
+			return view;
 		} );
 	}
 };

+ 9 - 12
packages/ckeditor5-ui/tests/componentfactory.js

@@ -25,33 +25,30 @@ describe( 'ComponentFactory', () => {
 
 	describe( 'add', () => {
 		it( 'throws when trying to override already registered component', () => {
-			factory.add( 'foo', class {}, class {}, {} );
+			factory.add( 'foo', () => {} );
 
 			expect( () => {
-				factory.add( 'foo', class {}, class {}, {} );
+				factory.add( 'foo', () => {} );
 			} ).to.throw( CKEditorError, /^componentfactory-item-exists/ );
 		} );
 	} );
 
 	describe( 'create', () => {
 		it( 'creates an instance', () => {
-			class View {}
-
-			class Controller {
-				constructor( model, view, ed ) {
-					expect( model ).to.equal( model );
-					expect( view ).to.be.instanceof( View );
-					expect( ed ).to.equal( editor );
+			class View {
+				constructor( locale ) {
+					this.locale = locale;
 				}
 			}
 
-			const model = {};
+			const locale = editor.locale = {};
 
-			factory.add( 'foo', Controller, View, model );
+			factory.add( 'foo', locale => new View( locale ) );
 
 			const instance = factory.create( 'foo' );
 
-			expect( instance ).to.be.instanceof( Controller );
+			expect( instance ).to.be.instanceof( View );
+			expect( instance.locale ).to.equal( locale );
 		} );
 	} );
 } );

+ 0 - 565
packages/ckeditor5-ui/tests/controller.js

@@ -1,565 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* globals document */
-/* bender-tags: ui */
-
-import testUtils from 'tests/core/_utils/utils.js';
-import View from 'ckeditor5/ui/view.js';
-import Controller from 'ckeditor5/ui/controller.js';
-import ControllerCollection from 'ckeditor5/ui/controllercollection.js';
-import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
-import Model from 'ckeditor5/ui/model.js';
-import EventInfo from 'ckeditor5/utils/eventinfo.js';
-
-let ParentController, ParentView;
-
-testUtils.createSinonSandbox();
-
-describe( 'Controller', () => {
-	describe( 'constructor()', () => {
-		it( 'defines basic properties', () => {
-			const controller = new Controller();
-
-			expect( controller.model ).to.be.null;
-			expect( controller.ready ).to.be.false;
-			expect( controller.view ).to.be.null;
-
-			expect( controller.collections.length ).to.equal( 1 );
-			expect( controller.collections.get( '$anonymous' ) ).to.be.instanceOf( ControllerCollection );
-		} );
-
-		it( 'should accept model and view', () => {
-			const model = new Model();
-			const view = new View();
-			const controller = new Controller( model, view );
-
-			expect( controller.model ).to.equal( model );
-			expect( controller.view ).to.equal( view );
-		} );
-	} );
-
-	describe( 'init', () => {
-		it( 'should throw when already initialized', () => {
-			const controller = new Controller();
-
-			return controller.init()
-				.then( () => {
-					controller.init();
-
-					throw new Error( 'This should not be executed.' );
-				} )
-				.catch( ( err ) => {
-					expect( err ).to.be.instanceof( CKEditorError );
-					expect( err.message ).to.match( /ui-controller-init-re/ );
-				} );
-		} );
-
-		it( 'should set #ready flag and fire #ready event', () => {
-			const controller = new Controller();
-			const spy = sinon.spy( () => {
-				expect( controller ).to.have.property( 'ready', true );
-			} );
-
-			controller.on( 'ready', spy );
-
-			return controller.init().then( () => {
-				expect( spy.calledOnce ).to.be.true;
-			} );
-		} );
-
-		it( 'should initialize own view', () => {
-			const view = new View();
-			const controller = new Controller( null, view );
-			const spy = testUtils.sinon.spy( view, 'init' );
-
-			return controller.init().then( () => {
-				sinon.assert.calledOnce( spy );
-			} );
-		} );
-
-		it( 'should initialize child controllers in own collections', () => {
-			const parentController = new Controller();
-			const buttonCollection = new ControllerCollection( 'buttons' );
-			parentController.collections.add( buttonCollection );
-
-			const childController1 = new Controller();
-			const childController2 = new Controller();
-			const spy1 = testUtils.sinon.spy( childController1, 'init' );
-			const spy2 = testUtils.sinon.spy( childController2, 'init' );
-
-			buttonCollection.add( childController1 );
-			buttonCollection.add( childController2 );
-
-			return parentController.init().then( () => {
-				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 );
-			} );
-		} );
-	} );
-
-	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();
-			const child2 = new Controller();
-			const collection = parentController.collections.get( 'x' );
-
-			parentController.add( 'x', child1 );
-			parentController.add( 'x', child2 );
-
-			expect( collection ).to.have.length( 2 );
-			expect( collection.get( 0 ) ).to.equal( child1 );
-			expect( collection.get( 1 ) ).to.equal( child2 );
-		} );
-
-		it( 'should add a controller at specific index', () => {
-			const parentController = new ParentController();
-			const child1 = new Controller();
-			const child2 = new Controller();
-			const collection = parentController.collections.get( 'x' );
-
-			parentController.add( 'x', child1 );
-			parentController.add( 'x', child2, 0 );
-
-			expect( collection ).to.have.length( 2 );
-			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 );
-		} );
-	} );
-
-	describe( 'remove', () => {
-		beforeEach( defineParentControllerClass );
-
-		it( 'should remove a controller from specific collection – by instance', () => {
-			const parentController = new ParentController();
-			const child1 = new Controller();
-			const child2 = new Controller();
-			const child3 = new Controller();
-			const collection = parentController.collections.get( 'x' );
-
-			parentController.add( 'x', child1 );
-			parentController.add( 'x', child2 );
-			parentController.add( 'x', child3 );
-
-			const removed = parentController.remove( 'x', 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 );
-		} );
-
-		it( 'should remove a controller from specific collection – by index', () => {
-			const parentController = new ParentController();
-			const child1 = new Controller();
-			const child2 = new Controller();
-			const child3 = new Controller();
-			const collection = parentController.collections.get( 'x' );
-
-			parentController.add( 'x', child1 );
-			parentController.add( 'x', child2 );
-			parentController.add( 'x', child3 );
-
-			const removed = parentController.remove( 'x', 1 );
-
-			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 );
-		} );
-
-		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 );
-		} );
-	} );
-
-	describe( 'collections', () => {
-		describe( 'add', () => {
-			beforeEach( defineParentViewClass );
-			beforeEach( defineParentControllerClass );
-
-			it( 'should add a child controller which has no view', () => {
-				const parentController = new ParentController( null, new ParentView() );
-				const collection = parentController.collections.get( 'x' );
-				const childController = new Controller();
-
-				return parentController.init()
-					.then( () => {
-						return collection.add( childController );
-					} )
-					.then( () => {
-						expect( collection.get( 0 ) ).to.equal( childController );
-					} );
-			} );
-
-			it( 'should append child controller\'s view to parent controller\'s view', () => {
-				const parentView = new ParentView();
-				const parentController = new ParentController( null, parentView );
-				const collection = parentController.collections.get( 'x' );
-				const childController = new Controller( null, new View() );
-				const spy = testUtils.sinon.spy();
-
-				parentView.regions.get( 'x' ).views.on( 'add', spy );
-
-				collection.add( childController );
-
-				sinon.assert.notCalled( spy );
-
-				collection.remove( childController );
-
-				return parentController.init()
-					.then( () => {
-						return collection.add( childController );
-					} )
-					.then( () => {
-						sinon.assert.calledOnce( spy );
-						sinon.assert.calledWithExactly( spy,
-							sinon.match.instanceOf( EventInfo ), childController.view, 0 );
-					} );
-			} );
-
-			it( 'should append child controller\'s view to parent controller\'s view at given index', () => {
-				const parentController = new ParentController( null, new ParentView() );
-				const collection = parentController.collections.get( 'x' );
-
-				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 collection.add( childController1 ).then( () => {
-							return collection.add( childController2, 0 );
-						} );
-					} )
-					.then( () => {
-						const region = parentController.view.regions.get( 'x' );
-
-						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 );
-					} );
-			} );
-		} );
-
-		describe( 'remove', () => {
-			beforeEach( defineParentViewClass );
-
-			it( 'should remove child controller\'s view from parent controller\'s view', () => {
-				const parentView = new ParentView();
-				const parentController = new ParentController( null, parentView );
-				const collection = parentController.collections.get( 'x' );
-				const childController = new Controller( null, new View() );
-				const spy = testUtils.sinon.spy();
-				parentView.regions.get( 'x' ).views.on( 'remove', spy );
-
-				collection.add( childController );
-
-				sinon.assert.notCalled( spy );
-
-				return parentController.init()
-					.then( () => {
-						collection.remove( childController );
-						sinon.assert.calledOnce( spy );
-						sinon.assert.calledWithExactly( spy,
-							sinon.match.instanceOf( EventInfo ), childController.view );
-					} );
-			} );
-		} );
-	} );
-
-	describe( 'destroy', () => {
-		beforeEach( defineParentViewClass );
-		beforeEach( defineParentControllerClass );
-
-		it( 'should destroy the controller', () => {
-			const view = new View();
-			const controller = new Controller( null, view );
-			const spy = testUtils.sinon.spy( view, 'destroy' );
-
-			return controller.init()
-				.then( () => {
-					return controller.destroy();
-				} )
-				.then( () => {
-					sinon.assert.calledOnce( spy );
-
-					expect( controller.model ).to.be.null;
-					expect( controller.ready ).to.be.null;
-					expect( controller.view ).to.be.null;
-					expect( controller.collections ).to.be.null;
-				} );
-		} );
-
-		it( 'should destroy the controller which has no view', () => {
-			const controller = new Controller( null, null );
-
-			return controller.init()
-				.then( () => {
-					return controller.destroy();
-				} )
-				.then( () => {
-					expect( controller.model ).to.be.null;
-					expect( controller.view ).to.be.null;
-					expect( controller.collections ).to.be.null;
-				} );
-		} );
-
-		it( 'should destroy child controllers in collections with their views', () => {
-			const parentController = new ParentController( null, new ParentView() );
-			const collection = parentController.collections.get( 'x' );
-			const childView = new View();
-			const childController = new Controller( null, childView );
-			const spy = testUtils.sinon.spy( childView, 'destroy' );
-
-			collection.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;
-				} );
-		} );
-
-		it( 'should destroy child controllers in collections when they have no views', () => {
-			const parentController = new ParentController( null, new ParentView() );
-			const collection = parentController.collections.get( 'x' );
-			const childController = new Controller( null, null );
-
-			collection.add( childController );
-
-			return parentController.init()
-				.then( () => {
-					return parentController.destroy();
-				} )
-				.then( () => {
-					expect( childController.model ).to.be.null;
-					expect( childController.view ).to.be.null;
-					expect( childController.collections ).to.be.null;
-				} );
-		} );
-
-		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();
-			const controllerCollectionCollection = parentController.collections; // Yep... it's correct :D.
-			const childControllers = [];
-			const collections = [ 'a', 'b', 'c' ].map( name => {
-				const collection = new ControllerCollection( name );
-				const childController = new Controller();
-
-				childController.destroy = sinon.spy();
-
-				parentController.collections.add( collection );
-				collection.add( childController );
-				childControllers.push( childController );
-
-				return collection;
-			} );
-
-			return parentController.init()
-				.then( () => {
-					return parentController.destroy();
-				} )
-				.then( () => {
-					expect( controllerCollectionCollection ).to.have.lengthOf( 0, 'parentController.collections is empty' );
-					expect( collections.map( collection => collection.length ) )
-						.to.deep.equal( [ 0, 0, 0 ], 'all collections are empty' );
-					expect( childControllers.map( controller => controller.destroy.calledOnce ) )
-						.to.deep.equal( [ true, true, true ], 'all child controllers were destroyed' );
-				} );
-		} );
-
-		// See #11
-		it( 'should correctly destroy collections with multiple child controllers', () => {
-			const parentController = new Controller();
-			const controllerCollectionCollection = parentController.collections; // Yep... it's correct :D.
-			const controllerCollection = new ControllerCollection( 'foo' );
-			const childControllers = [];
-
-			parentController.collections.add( controllerCollection );
-
-			for ( let i = 0; i < 3; i++ ) {
-				const childController = new Controller();
-
-				childController.destroy = sinon.spy();
-
-				childControllers.push( childController );
-				parentController.add( 'foo', childController );
-			}
-
-			return parentController.init()
-				.then( () => {
-					return parentController.destroy();
-				} )
-				.then( () => {
-					expect( controllerCollectionCollection ).to.have.lengthOf( 0, 'parentController.collections is empty' );
-					expect( controllerCollection ).to.have.lengthOf( 0, 'child controller collection is empty' );
-					expect( childControllers.map( controller => controller.destroy.calledOnce ) )
-						.to.deep.equal( [ true, true, true ], 'all child controllers were destroyed' );
-				} );
-		} );
-	} );
-
-	describe( 'addCollection', () => {
-		it( 'should add a new collection', () => {
-			const controller = new Controller();
-
-			controller.addCollection( 'foo' );
-
-			expect( controller.collections ).to.have.length( 2 );
-			expect( controller.collections.get( 'foo' ).name ).to.equal( 'foo' );
-		} );
-
-		it( 'should return the collection which has been created (chaining)', () => {
-			const controller = new Controller();
-			const returned = controller.addCollection( 'foo' );
-
-			expect( returned ).to.be.instanceOf( ControllerCollection );
-		} );
-
-		it( 'should pass locale to controller collection', () => {
-			const locale = {};
-			const view = new View( locale );
-
-			expect( new Controller( {}, view ).addCollection( 'foo' ).locale ).to.equal( locale );
-		} );
-	} );
-} );
-
-function defineParentViewClass() {
-	ParentView = class extends View {
-		constructor() {
-			super();
-
-			this.element = document.createElement( 'span' );
-			this.register( 'x', true );
-		}
-	};
-}
-
-function defineParentControllerClass() {
-	ParentController = class extends Controller {
-		constructor( ...args ) {
-			super( ...args );
-
-			this.addCollection( 'x' );
-		}
-	};
-}

+ 0 - 629
packages/ckeditor5-ui/tests/controllercollection.js

@@ -1,629 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* globals document */
-/* bender-tags: ui */
-
-import testUtils from 'tests/core/_utils/utils.js';
-import ControllerCollection from 'ckeditor5/ui/controllercollection.js';
-import Controller from 'ckeditor5/ui/controller.js';
-import Collection from 'ckeditor5/utils/collection.js';
-import Model from 'ckeditor5/ui/model.js';
-import View from 'ckeditor5/ui/view.js';
-import Template from 'ckeditor5/ui/template.js';
-import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
-
-testUtils.createSinonSandbox();
-
-let ParentView, ItemController, ItemView;
-let models;
-
-describe( 'ControllerCollection', () => {
-	beforeEach( () => {
-		defineParentViewClass();
-		defineItemControllerClass();
-		defineItemViewClass();
-		createModelCollection();
-	} );
-
-	describe( 'constructor()', () => {
-		it( 'should throw when no name is passed', () => {
-			expect( () => {
-				new ControllerCollection();
-			} ).to.throw( /^ui-controllercollection-no-name/ );
-		} );
-
-		it( 'accepts locale', () => {
-			const locale = {};
-			const collection = new ControllerCollection( 'foo', locale );
-
-			expect( collection.locale ).to.equal( locale );
-		} );
-	} );
-
-	describe( 'add', () => {
-		it( 'should add a child controller and return promise', () => {
-			const parentController = new Controller();
-			const childController = new Controller();
-			const collection = parentController.addCollection( 'x' );
-
-			const returned = collection.add( childController );
-
-			expect( returned ).to.be.an.instanceof( Promise );
-			expect( collection.get( 0 ) ).to.be.equal( childController );
-		} );
-
-		it( 'should add a child controller at given position', () => {
-			const parentController = new Controller();
-			const childController1 = new Controller();
-			const childController2 = new Controller();
-			const collection = parentController.addCollection( 'x' );
-
-			collection.add( childController1 );
-			collection.add( childController2, 0 );
-
-			expect( collection.get( 0 ) ).to.be.equal( childController2 );
-			expect( collection.get( 1 ) ).to.be.equal( childController1 );
-		} );
-
-		it( 'should initialize child controller if parent is ready', () => {
-			const parentController = new Controller( null, new ParentView() );
-			const childController = new Controller( null, new View() );
-			const spy = testUtils.sinon.spy( childController, 'init' );
-			const collection = parentController.addCollection( 'x' );
-
-			collection.add( childController );
-			collection.remove( childController );
-
-			sinon.assert.notCalled( spy );
-
-			return parentController.init()
-				.then( () => {
-					return collection.add( childController );
-				} )
-				.then( () => {
-					sinon.assert.calledOnce( spy );
-				} );
-		} );
-
-		it( 'should not initialize child controller twice', () => {
-			const parentController = new Controller( null, new ParentView() );
-			const childController = new Controller( null, new View() );
-			const spy = testUtils.sinon.spy( childController, 'init' );
-			const collection = parentController.addCollection( 'x' );
-
-			return parentController.init()
-				.then( () => {
-					return childController.init();
-				} )
-				.then( () => {
-					return collection.add( childController );
-				} )
-				.then( () => {
-					sinon.assert.calledOnce( spy );
-				} );
-		} );
-	} );
-
-	describe( 'bind', () => {
-		it( 'returns object', () => {
-			expect( new ControllerCollection( 'foo' ).bind( {} ) ).to.be.an( 'object' );
-		} );
-
-		it( 'provides "as" interface', () => {
-			const bind = new ControllerCollection( 'foo' ).bind( {} );
-
-			expect( bind ).to.have.keys( 'as' );
-			expect( bind.as ).to.be.a( 'function' );
-		} );
-
-		describe( 'as', () => {
-			it( 'does not chain', () => {
-				const controllers = new ControllerCollection( 'synced' );
-				const returned = controllers.bind( models ).as( ItemController, ItemView );
-
-				expect( returned ).to.be.undefined;
-			} );
-
-			describe( 'standard handler', () => {
-				it( 'expands the initial collection of the models', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ItemController, ItemView );
-
-					expect( controllers ).to.have.length( 5 );
-					expect( controllers.get( 0 ).model.uid ).to.equal( '0' );
-					expect( controllers.get( 4 ).model.uid ).to.equal( '4' );
-				} );
-
-				it( 'uses the controller and view classes to expand the collection', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ItemController, ItemView );
-
-					expect( controllers.get( 0 ) ).to.be.instanceOf( ItemController );
-					expect( controllers.get( 0 ).view ).to.be.instanceOf( ItemView );
-				} );
-
-				it( 'supports adding new models to the collection', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ItemController, ItemView );
-
-					models.add( new Model( { uid: '6' } ) );
-					models.add( new Model( { uid: '5' } ), 5 );
-
-					expect( controllers.get( 5 ).model.uid ).to.equal( '5' );
-					expect( controllers.get( 6 ).model.uid ).to.equal( '6' );
-					expect( controllers ).to.have.length( 7 );
-				} );
-
-				it( 'supports adding to the beginning of the collection', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ItemController, ItemView );
-
-					models.add( new Model( { uid: 'x' } ), 0 );
-
-					expect( controllers.map( c => c.model.uid ) ).to.deep.equal( [ 'x', '0', '1', '2', '3', '4' ] );
-				} );
-
-				it( 'supports removing models from the collection', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ItemController, ItemView );
-
-					models.remove( 2 );
-					models.remove( 3 );
-
-					expect( controllers.map( c => c.model.uid ) ).to.have.members( [ '0', '1', '3' ] );
-				} );
-
-				it( 'passes controller collection\'s locale to the views', () => {
-					const locale = {};
-					const controllers = new ControllerCollection( 'synced', locale );
-
-					controllers.bind( models ).as( ItemController, ItemView );
-
-					expect( controllers.get( 0 ).view.locale ).to.equal( locale );
-				} );
-			} );
-
-			describe( 'custom handler', () => {
-				it( 'expands the initial collection of the models', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ( model, locale ) => {
-						return new ItemController( model, new ItemView( locale ) );
-					} );
-
-					expect( controllers ).to.have.length( 5 );
-					expect( controllers.get( 0 ).model.uid ).to.equal( '0' );
-					expect( controllers.get( 4 ).model.uid ).to.equal( '4' );
-				} );
-
-				it( 'uses the controller and view classes to expand the collection', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ( model, locale ) => {
-						return new ItemController( model, new ItemView( locale ) );
-					} );
-
-					expect( controllers.get( 0 ) ).to.be.instanceOf( ItemController );
-					expect( controllers.get( 0 ).view ).to.be.instanceOf( ItemView );
-				} );
-
-				it( 'supports adding new models to the collection', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ( model, locale ) => {
-						return new ItemController( model, new ItemView( locale ) );
-					} );
-
-					models.add( new Model( { uid: '6' } ) );
-					models.add( new Model( { uid: '5' } ), 5 );
-
-					expect( controllers.get( 5 ).model.uid ).to.equal( '5' );
-					expect( controllers.get( 6 ).model.uid ).to.equal( '6' );
-					expect( controllers ).to.have.length( 7 );
-				} );
-
-				it( 'supports removing models from the collection', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ( model, locale ) => {
-						return new ItemController( model, new ItemView( locale ) );
-					} );
-
-					models.remove( 2 );
-					models.remove( 3 );
-
-					expect( controllers.map( c => c.model.uid ) ).to.have.members( [ '0', '1', '3' ] );
-				} );
-
-				it( 'supports returning null', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ( model, locale ) => {
-						if ( model.uid == 2 ) {
-							return null;
-						} else {
-							return new ItemController( model, new ItemView( locale ) );
-						}
-					} );
-
-					expect( controllers.map( c => c.model.uid ) ).to.have.members( [ '0', '1', '3', '4' ] );
-				} );
-
-				it( 'supports adding to structure with missing controller', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ( model, locale ) => {
-						if ( model.uid == 2 ) {
-							return null;
-						} else {
-							return new ItemController( model, new ItemView( locale ) );
-						}
-					} );
-
-					models.add( new Model( { uid: '0.5' } ), 1 );
-					models.add( new Model( { uid: '3.5' } ), 5 );
-
-					expect( models.map( c => c.uid ) ).to.have.members( [ '0', '0.5', '1', '2', '3', '3.5', '4' ] );
-					expect( controllers.map( c => c.model.uid ) ).to.have.members( [ '0', '0.5', '1', '3', '3.5', '4' ] );
-				} );
-
-				it( 'supports removing to structure with missing controller', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ( model, locale ) => {
-						if ( model.uid == 2 ) {
-							return null;
-						} else {
-							return new ItemController( model, new ItemView( locale ) );
-						}
-					} );
-
-					models.remove( 3 );
-
-					expect( controllers.map( c => c.model.uid ) ).to.have.members( [ '0', '1', '4' ] );
-				} );
-
-				it( 'supports removing model with missing controller', () => {
-					const controllers = new ControllerCollection( 'synced' );
-
-					controllers.bind( models ).as( ( model, locale ) => {
-						if ( model.uid == 2 ) {
-							return null;
-						} else {
-							return new ItemController( model, new ItemView( locale ) );
-						}
-					} );
-
-					models.remove( 2 );
-
-					expect( controllers.map( c => c.model.uid ) ).to.have.members( [ '0', '1', '3', '4' ] );
-				} );
-
-				it( 'passes controller collection\'s locale to the views', () => {
-					const locale = {};
-					const controllers = new ControllerCollection( 'synced', locale );
-
-					controllers.bind( models ).as( ( model, locale ) => {
-						return new ItemController( model, new ItemView( locale ) );
-					} );
-
-					expect( controllers.get( 0 ).view.locale ).to.equal( locale );
-				} );
-			} );
-
-			describe( 'custom data format with custom handler', () => {
-				it( 'expands the initial collection of the models', () => {
-					const controllers = new ControllerCollection( 'synced' );
-					const data = new Collection();
-
-					data.add( { foo: 'a' } );
-					data.add( { foo: 'b' } );
-
-					controllers.bind( data ).as( ( item, locale ) => {
-						const model = new Model( {
-							custom: item.foo
-						} );
-
-						return new ItemController( model, new ItemView( locale ) );
-					} );
-
-					expect( controllers ).to.have.length( 2 );
-					expect( controllers.get( 0 ).model.custom ).to.equal( 'a' );
-					expect( controllers.get( 1 ).model.custom ).to.equal( 'b' );
-					expect( controllers.get( 0 ) ).to.be.instanceOf( ItemController );
-					expect( controllers.get( 0 ).view ).to.be.instanceOf( ItemView );
-				} );
-			} );
-		} );
-	} );
-
-	describe( 'delegate', () => {
-		it( 'should throw when event names are not strings', () => {
-			const collection = new ControllerCollection( 'foo' );
-
-			expect( () => {
-				collection.delegate();
-			} ).to.throw( CKEditorError, /ui-controllercollection-delegate-wrong-events/ );
-
-			expect( () => {
-				collection.delegate( new Date() );
-			} ).to.throw( CKEditorError, /ui-controllercollection-delegate-wrong-events/ );
-
-			expect( () => {
-				collection.delegate( 'color', new Date() );
-			} ).to.throw( CKEditorError, /ui-controllercollection-delegate-wrong-events/ );
-		} );
-
-		it( 'returns object', () => {
-			expect( new ControllerCollection( 'foo' ).delegate( 'foo' ) ).to.be.an( 'object' );
-		} );
-
-		it( 'provides "to" interface', () => {
-			const delegate = new ControllerCollection( 'foo' ).delegate( 'foo' );
-
-			expect( delegate ).to.have.keys( 'to' );
-			expect( delegate.to ).to.be.a( 'function' );
-		} );
-
-		describe( 'to', () => {
-			it( 'does not chain', () => {
-				const collection = new ControllerCollection( 'foo' );
-				const returned = collection.delegate( 'foo' ).to( {} );
-
-				expect( returned ).to.be.undefined;
-			} );
-
-			it( 'forwards an event to another observable – existing controller', ( done ) => {
-				const target = new Model();
-				const collection = new ControllerCollection( 'foo' );
-				const model = new Model();
-
-				collection.add( new Controller( model ) );
-				collection.delegate( 'foo' ).to( target );
-
-				target.on( 'foo', ( ...args ) => {
-					assertDelegated( args, {
-						expectedName: 'foo',
-						expectedSource: model,
-						expectedPath: [ model, target ],
-						expectedData: []
-					} );
-
-					done();
-				} );
-
-				model.fire( 'foo' );
-			} );
-
-			it( 'forwards an event to another observable – new controller', ( done ) => {
-				const target = new Model();
-				const collection = new ControllerCollection( 'foo' );
-				const model = new Model();
-
-				collection.delegate( 'foo' ).to( target );
-				collection.add( new Controller( model ) );
-
-				target.on( 'foo', ( ...args ) => {
-					assertDelegated( args, {
-						expectedName: 'foo',
-						expectedSource: model,
-						expectedPath: [ model, target ],
-						expectedData: []
-					} );
-
-					done();
-				} );
-
-				model.fire( 'foo' );
-			} );
-
-			it( 'forwards multiple events to another observable', () => {
-				const target = new Model();
-				const collection = new ControllerCollection( 'foo' );
-				const modelA = new Model();
-				const modelB = new Model();
-				const modelC = new Model();
-				const spyFoo = sinon.spy();
-				const spyBar = sinon.spy();
-				const spyBaz = sinon.spy();
-
-				collection.delegate( 'foo', 'bar', 'baz' ).to( target );
-				collection.add( new Controller( modelA ) );
-				collection.add( new Controller( modelB ) );
-				collection.add( new Controller( modelC ) );
-
-				target.on( 'foo', spyFoo );
-				target.on( 'bar', spyBar );
-				target.on( 'baz', spyBaz );
-
-				modelA.fire( 'foo' );
-
-				sinon.assert.calledOnce( spyFoo );
-				sinon.assert.notCalled( spyBar );
-				sinon.assert.notCalled( spyBaz );
-
-				assertDelegated( spyFoo.args[ 0 ], {
-					expectedName: 'foo',
-					expectedSource: modelA,
-					expectedPath: [ modelA, target ],
-					expectedData: []
-				} );
-
-				modelB.fire( 'bar' );
-
-				sinon.assert.calledOnce( spyFoo );
-				sinon.assert.calledOnce( spyBar );
-				sinon.assert.notCalled( spyBaz );
-
-				assertDelegated( spyBar.args[ 0 ], {
-					expectedName: 'bar',
-					expectedSource: modelB,
-					expectedPath: [ modelB, target ],
-					expectedData: []
-				} );
-
-				modelC.fire( 'baz' );
-
-				sinon.assert.calledOnce( spyFoo );
-				sinon.assert.calledOnce( spyBar );
-				sinon.assert.calledOnce( spyBaz );
-
-				assertDelegated( spyBaz.args[ 0 ], {
-					expectedName: 'baz',
-					expectedSource: modelC,
-					expectedPath: [ modelC, target ],
-					expectedData: []
-				} );
-
-				modelC.fire( 'not-delegated' );
-
-				sinon.assert.calledOnce( spyFoo );
-				sinon.assert.calledOnce( spyBar );
-				sinon.assert.calledOnce( spyBaz );
-			} );
-
-			it( 'does not forward events which are not supposed to be delegated', () => {
-				const target = new Model();
-				const collection = new ControllerCollection( 'foo' );
-				const model = new Model();
-				const spyFoo = sinon.spy();
-				const spyBar = sinon.spy();
-				const spyBaz = sinon.spy();
-
-				collection.delegate( 'foo', 'bar', 'baz' ).to( target );
-				collection.add( new Controller( model ) );
-
-				target.on( 'foo', spyFoo );
-				target.on( 'bar', spyBar );
-				target.on( 'baz', spyBaz );
-
-				model.fire( 'foo' );
-				model.fire( 'bar' );
-				model.fire( 'baz' );
-				model.fire( 'not-delegated' );
-
-				sinon.assert.callOrder( spyFoo, spyBar, spyBaz );
-				sinon.assert.callCount( spyFoo, 1 );
-				sinon.assert.callCount( spyBar, 1 );
-				sinon.assert.callCount( spyBaz, 1 );
-			} );
-
-			it( 'stops forwarding when controller removed from the collection', () => {
-				const target = new Model();
-				const collection = new ControllerCollection( 'foo' );
-				const model = new Model();
-				const spy = sinon.spy();
-
-				collection.delegate( 'foo' ).to( target );
-				target.on( 'foo', spy );
-
-				collection.add( new Controller( model ) );
-				model.fire( 'foo' );
-
-				sinon.assert.callCount( spy, 1 );
-
-				collection.remove( 0 );
-				model.fire( 'foo' );
-
-				sinon.assert.callCount( spy, 1 );
-			} );
-
-			it( 'supports deep event delegation', ( done ) => {
-				const collection = new ControllerCollection( 'foo' );
-				const target = new Model();
-				const modelA = new Model();
-				const modelAA = new Model();
-				const data = {};
-
-				const controllerA = new Controller( modelA );
-				const controllerAA = new Controller( modelAA );
-				const barCollection = controllerA.addCollection( 'bar' );
-
-				collection.add( controllerA );
-				collection.delegate( 'foo' ).to( target );
-
-				barCollection.add( controllerAA );
-				barCollection.delegate( 'foo' ).to( modelA );
-
-				target.on( 'foo', ( ...args ) => {
-					assertDelegated( args, {
-						expectedName: 'foo',
-						expectedSource: modelAA,
-						expectedPath: [ modelAA, modelA, target ],
-						expectedData: [ data ]
-					} );
-
-					done();
-				} );
-
-				modelAA.fire( 'foo', data );
-			} );
-		} );
-	} );
-} );
-
-function defineParentViewClass() {
-	ParentView = class extends View {
-		constructor() {
-			super();
-
-			this.element = document.createElement( 'span' );
-			this.register( 'x', true );
-		}
-	};
-}
-
-function defineItemControllerClass() {
-	ItemController = class extends Controller {
-		constructor( model, view ) {
-			super( model, view );
-
-			view.bind( 'uid' ).to( model );
-		}
-	};
-}
-
-function defineItemViewClass() {
-	ItemView = class extends View {
-		constructor( locale ) {
-			super( locale );
-
-			const bind = this.bindTemplate;
-
-			this.template = new Template( {
-				tag: 'li',
-
-				attributes: {
-					id: bind.to( 'uid' )
-				}
-			} );
-		}
-	};
-}
-
-function createModelCollection() {
-	models = new Collection( { idProperty: 'uid' } );
-
-	for ( let i = 0; i < 5; i++ ) {
-		models.add( new Model( {
-			uid: Number( i ).toString()
-		} ) );
-	}
-}
-
-function assertDelegated( evtArgs, { expectedName, expectedSource, expectedPath, expectedData } ) {
-	const evtInfo = evtArgs[ 0 ];
-
-	expect( evtInfo.name ).to.equal( expectedName );
-	expect( evtInfo.source ).to.equal( expectedSource );
-	expect( evtInfo.path ).to.deep.equal( expectedPath );
-	expect( evtArgs.slice( 1 ) ).to.deep.equal( expectedData );
-}

+ 4 - 0
packages/ckeditor5-ui/tests/domemittermixin.js

@@ -21,6 +21,10 @@ describe( 'DOMEmitterMixin', () => {
 		node = document.createElement( 'div' );
 	} );
 
+	afterEach( () => {
+		domEmitter.stopListening();
+	} );
+
 	describe( 'listenTo', () => {
 		it( 'should listen to EmitterMixin events', () => {
 			const spy = testUtils.sinon.spy();

+ 0 - 140
packages/ckeditor5-ui/tests/region.js

@@ -1,140 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* global document */
-/* bender-tags: ui */
-
-import Region from 'ckeditor5/ui/region.js';
-import View from 'ckeditor5/ui/view.js';
-import Template from 'ckeditor5/ui/template.js';
-
-let TestViewA, TestViewB;
-let region, el;
-
-describe( 'View', () => {
-	beforeEach( createRegionInstance );
-
-	describe( 'constructor()', () => {
-		it( 'accepts name', () => {
-			expect( region.name ).to.be.equal( 'foo' );
-			expect( region.element ).to.be.null;
-			expect( region.views.length ).to.be.equal( 0 );
-		} );
-	} );
-
-	describe( 'init', () => {
-		it( 'accepts region element', () => {
-			region.init( el );
-
-			expect( region.element ).to.be.equal( el );
-		} );
-	} );
-
-	describe( 'views collection', () => {
-		it( 'updates DOM when adding views', () => {
-			let view;
-
-			region.init( el );
-
-			expect( region.element.childNodes.length ).to.be.equal( 0 );
-
-			view = new TestViewA();
-			region.views.add( view, 0 );
-			expect( region.element.childNodes.length ).to.be.equal( 1 );
-
-			region.views.add( new TestViewA() );
-			expect( region.element.childNodes.length ).to.be.equal( 2 );
-
-			view = new TestViewA();
-			region.views.add( view, 1 );
-			expect( region.element.childNodes.length ).to.be.equal( 3 );
-			expect( region.element.childNodes[ 1 ] ).to.equal( view.element );
-
-			view = new TestViewA();
-			region.views.add( view, 0 );
-			expect( region.element.childNodes.length ).to.be.equal( 4 );
-			expect( region.element.childNodes[ 0 ] ).to.equal( view.element );
-		} );
-
-		it( 'does not update DOM when no region element', () => {
-			region.init();
-
-			expect( () => {
-				region.views.add( new TestViewA() );
-				region.views.add( new TestViewA() );
-			} ).to.not.throw();
-		} );
-
-		it( 'updates DOM when removing views', () => {
-			region.init( el );
-
-			let viewA = new TestViewA();
-			let viewB = new TestViewB();
-
-			region.views.add( viewA );
-			region.views.add( viewB );
-
-			expect( el.childNodes.length ).to.be.equal( 2 );
-			expect( el.firstChild.nodeName ).to.be.equal( 'A' );
-			expect( el.lastChild.nodeName ).to.be.equal( 'B' );
-
-			region.views.remove( viewA );
-			expect( el.childNodes.length ).to.be.equal( 1 );
-			expect( el.firstChild.nodeName ).to.be.equal( 'B' );
-
-			region.views.remove( viewB );
-			expect( el.childNodes.length ).to.be.equal( 0 );
-		} );
-	} );
-
-	describe( 'destroy', () => {
-		it( 'destroys the region', () => {
-			region.init( el );
-			region.views.add( new TestViewA() );
-
-			const elRef = region.element;
-			const viewsRef = region.views;
-
-			region.destroy();
-
-			expect( elRef.parentNode ).to.be.null;
-			expect( region.name ).to.be.equal( 'foo' );
-			expect( region.views ).to.be.null;
-			expect( viewsRef.length ).to.be.equal( 0 );
-			expect( region.element ).to.be.null;
-		} );
-
-		it( 'destroys an element–less region', () => {
-			region.init();
-
-			expect( () => {
-				region.destroy();
-			} ).to.not.throw();
-		} );
-	} );
-} );
-
-function createRegionInstance() {
-	class A extends View {
-		constructor() {
-			super();
-			this.template = new Template( { tag: 'a' } );
-		}
-	}
-
-	class B extends View {
-		constructor() {
-			super();
-			this.template = new Template( { tag: 'b' } );
-		}
-	}
-
-	TestViewA = A;
-	TestViewB = B;
-
-	el = document.createElement( 'div' );
-
-	region = new Region( 'foo' );
-}

+ 61 - 0
packages/ckeditor5-ui/tests/template.js

@@ -10,6 +10,7 @@ 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 ViewCollection from 'ckeditor5/ui/viewcollection.js';
 import Model from 'ckeditor5/ui/model.js';
 import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
 import EmitterMixin from 'ckeditor5/utils/emittermixin.js';
@@ -444,6 +445,45 @@ describe( 'Template', () => {
 				expect( v1.element ).to.equal( rendered.firstChild );
 				expect( v2.element ).to.equal( rendered.lastChild );
 			} );
+
+			it( 'renders view collection', () => {
+				const collection = new ViewCollection();
+
+				const v1 = getView( {
+					tag: 'span',
+					attributes: {
+						class: [
+							'v1'
+						]
+					}
+				} );
+
+				const v2 = getView( {
+					tag: 'span',
+					attributes: {
+						class: [
+							'v2'
+						]
+					}
+				} );
+
+				collection.add( v1 );
+				collection.add( v2 );
+
+				const rendered = new Template( {
+					tag: 'p',
+					children: collection
+				} ).render();
+
+				expect( normalizeHtml( rendered.outerHTML ) ).to.equal( '<p><span class="v1"></span><span class="v2"></span></p>' );
+
+				// 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( collection._parentElement ).to.equal( rendered );
+			} );
 		} );
 
 		describe( 'bindings', () => {
@@ -639,6 +679,27 @@ describe( 'Template', () => {
 				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div></div>' );
 			} );
 
+			it( 'doesn\'t apply new child to an HTMLElement – view collection', () => {
+				const collection = new ViewCollection();
+
+				collection.add( getView( {
+					tag: 'span',
+					attributes: {
+						class: [
+							'v1'
+						]
+					}
+				} ) );
+
+				new Template( {
+					tag: 'p',
+					children: collection
+				} ).apply( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div></div>' );
+				expect( collection._parentElement ).to.be.null;
+			} );
+
 			it( 'should work for deep DOM structure', () => {
 				const childA = document.createElement( 'a' );
 				const childB = document.createElement( 'b' );

+ 197 - 157
packages/ckeditor5-ui/tests/view.js

@@ -3,16 +3,17 @@
  * For licensing, see LICENSE.md.
  */
 
-/* global document, HTMLElement */
+/* global document, setTimeout, HTMLElement */
 /* bender-tags: ui */
 
 import testUtils from 'tests/core/_utils/utils.js';
 import View from 'ckeditor5/ui/view.js';
 import Template from 'ckeditor5/ui/template.js';
-import Region from 'ckeditor5/ui/region.js';
 import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
+import Collection from 'ckeditor5/utils/collection.js';
+import ViewCollection from 'ckeditor5/ui/viewcollection.js';
 
-let TestView, view;
+let TestView, view, childA, childB;
 
 testUtils.createSinonSandbox();
 
@@ -27,7 +28,11 @@ describe( 'View', () => {
 			view = new View();
 
 			expect( view.t ).to.be.undefined;
-			expect( view.regions.length ).to.equal( 0 );
+			expect( view.locale ).to.be.undefined;
+			expect( view.ready ).to.be.false;
+			expect( view.template ).to.be.undefined;
+			expect( view._viewCollections ).to.be.instanceOf( Collection );
+			expect( view._unboundChildren ).to.be.instanceOf( ViewCollection );
 		} );
 
 		it( 'defines the locale property and the "t" function', () => {
@@ -38,176 +43,128 @@ describe( 'View', () => {
 			expect( view.locale ).to.equal( locale );
 			expect( view.t ).to.equal( locale.t );
 		} );
-	} );
-
-	describe( 'init', () => {
-		beforeEach( () => {
-			setTestViewClass( {
-				tag: 'p',
-				children: [
-					{ tag: 'span' },
-					{ tag: 'strong' }
-				]
-			} );
-		} );
-
-		it( 'calls child regions #init', () => {
-			setTestViewInstance();
 
-			const region1 = new Region( 'x' );
-			const region2 = new Region( 'y' );
+		describe( '_viewCollections', () => {
+			it( 'manages #locale property', () => {
+				const locale = {
+					t() {}
+				};
 
-			view.register( region1, el => el );
-			view.register( region2, el => el );
+				const view = new View( locale );
+				const collection = new ViewCollection();
 
-			const spy1 = testUtils.sinon.spy( region1, 'init' );
-			const spy2 = testUtils.sinon.spy( region2, 'init' );
+				expect( view.locale ).to.equal( locale );
+				expect( collection.locale ).to.be.undefined;
 
-			view.init();
-
-			sinon.assert.calledOnce( spy1 );
-			sinon.assert.calledOnce( spy2 );
+				view._viewCollections.add( collection );
+				expect( collection.locale ).to.equal( view.locale );
+			} );
 		} );
+	} );
 
-		it( 'initializes view regions with string selector', () => {
+	describe( 'createCollection', () => {
+		beforeEach( () => {
+			setTestViewClass();
 			setTestViewInstance();
-
-			const region1 = new Region( 'x' );
-			const region2 = new Region( 'y' );
-
-			view.register( region1, 'span' );
-			view.register( region2, 'strong' );
-
-			view.init();
-
-			expect( region1.element ).to.equal( view.element.firstChild );
-			expect( region2.element ).to.equal( view.element.lastChild );
 		} );
 
-		it( 'initializes view regions with function selector', () => {
-			setTestViewInstance();
-
-			const region1 = new Region( 'x' );
-			const region2 = new Region( 'y' );
-
-			view.register( region1, el => el.firstChild );
-			view.register( region2, el => el.lastChild );
-
-			view.init();
-
-			expect( region1.element ).to.equal( view.element.firstChild );
-			expect( region2.element ).to.equal( view.element.lastChild );
+		it( 'returns an instance of view collection', () => {
+			expect( view.createCollection() ).to.be.instanceOf( ViewCollection );
 		} );
 
-		it( 'initializes view regions with boolean selector', () => {
-			setTestViewInstance();
-
-			const region1 = new Region( 'x' );
-			const region2 = new Region( 'y' );
+		it( 'adds a new collection to the #_viewCollections', () => {
+			expect( view._viewCollections ).to.have.length( 1 );
 
-			view.register( region1, true );
-			view.register( region2, true );
+			const collection = view.createCollection();
 
-			view.init();
-
-			expect( region1.element ).to.be.null;
-			expect( region2.element ).to.be.null;
+			expect( view._viewCollections ).to.have.length( 2 );
+			expect( view._viewCollections.get( 1 ) ).to.equal( collection );
 		} );
 	} );
 
-	describe( 'bind', () => {
+	describe( 'addChild', () => {
 		beforeEach( () => {
 			setTestViewClass();
 			setTestViewInstance();
 		} );
 
-		it( 'returns a shorthand for Template binding', () => {
-			expect( view.bindTemplate.to ).to.be.a( 'function' );
-			expect( view.bindTemplate.if ).to.be.a( 'function' );
+		it( 'should add a view to #_unboundChildren', () => {
+			expect( view._unboundChildren ).to.have.length( 0 );
 
-			const binding = view.bindTemplate.to( 'a' );
+			const child = {};
 
-			expect( binding.observable ).to.equal( view );
-			expect( binding.emitter ).to.equal( view );
+			view.addChild( child );
+			expect( view._unboundChildren ).to.have.length( 1 );
+			expect( view._unboundChildren.get( 0 ) ).to.equal( child );
 		} );
-	} );
 
-	describe( 'register', () => {
-		beforeEach( () => {
-			setTestViewClass();
-			setTestViewInstance();
-		} );
+		it( 'should support multiple ...arguments', () => {
+			expect( view._unboundChildren ).to.have.length( 0 );
 
-		it( 'should throw when first argument is neither Region instance nor string', () => {
-			expect( () => {
-				view.register( new Date() );
-			} ).to.throw( CKEditorError, /ui-view-register-wrongtype/ );
-		} );
+			const children = [ {}, {}, {} ];
 
-		it( 'should throw when missing the selector argument', () => {
-			expect( () => {
-				view.register( 'x' );
-			} ).to.throw( CKEditorError, /ui-view-register-badselector/ );
+			view.addChild( ...children );
+			expect( view._unboundChildren ).to.have.length( 3 );
 		} );
+	} );
 
-		it( 'should throw when selector argument is of a wrong type', () => {
-			expect( () => {
-				view.register( 'x', new Date() );
-			} ).to.throw( CKEditorError, /ui-view-register-badselector/ );
+	describe( 'init()', () => {
+		beforeEach( createViewWithChildren );
 
-			expect( () => {
-				view.register( 'x', false );
-			} ).to.throw( CKEditorError, /ui-view-register-badselector/ );
-		} );
+		it( 'should throw if already initialized', () => {
+			return view.init()
+				.then( () => {
+					view.init();
 
-		it( 'should throw when overriding an existing region but without override flag set', () => {
-			expect( () => {
-				view.register( 'x', true );
-				view.register( new Region( 'x' ), true );
-			} ).to.throw( CKEditorError, /ui-view-register-override/ );
+					throw new Error( 'This should not be executed.' );
+				} )
+				.catch( ( err ) => {
+					expect( err ).to.be.instanceof( CKEditorError );
+					expect( err.message ).to.match( /ui-view-init-re/ );
+				} );
 		} );
 
-		it( 'should register a new region with region name as a first argument', () => {
-			view.register( 'x', true );
-
-			expect( view.regions.get( 'x' ) ).to.be.an.instanceof( Region );
+		it( 'returns a promise', () => {
+			expect( view.init() ).to.be.instanceof( Promise );
 		} );
 
-		it( 'should register a new region with Region instance as a first argument', () => {
-			view.register( new Region( 'y' ), true );
+		it( 'should set view#ready', () => {
+			expect( view.ready ).to.be.false;
 
-			expect( view.regions.get( 'y' ) ).to.be.an.instanceof( Region );
-		} );
-
-		it( 'should override an existing region with override flag', () => {
-			view.template = new Template( {
-				tag: 'div',
-				children: [
-					{ tag: 'span' }
-				]
+			return view.init().then( () => {
+				expect( view.ready ).to.be.true;
 			} );
+		} );
 
-			const region1 = new Region( 'x' );
-			const region2 = new Region( 'x' );
+		it( 'calls init() on all view#_viewCollections', () => {
+			const collectionA = view.createCollection();
+			const collectionB = view.createCollection();
 
-			view.register( region1, true );
-			view.register( region2, true, true );
-			view.register( 'x', 'span', true );
+			const spyA = testUtils.sinon.spy( collectionA, 'init' );
+			const spyB = testUtils.sinon.spy( collectionB, 'init' );
 
-			view.init();
+			return view.init().then( () => {
+				sinon.assert.calledOnce( spyA );
+				sinon.assert.calledOnce( spyB );
+				sinon.assert.callOrder( spyA, spyB );
+			} );
+		} );
+	} );
 
-			expect( view.regions.get( 'x' ) ).to.equal( region2 );
-			expect( view.regions.get( 'x' ).element ).to.equal( view.element.firstChild );
+	describe( 'bind', () => {
+		beforeEach( () => {
+			setTestViewClass();
+			setTestViewInstance();
 		} );
 
-		it( 'should not override an existing region with the same region with override flag', () => {
-			const region = new Region( 'x' );
-			const spy = testUtils.sinon.spy( view.regions, 'remove' );
+		it( 'returns a shorthand for Template binding', () => {
+			expect( view.bindTemplate.to ).to.be.a( 'function' );
+			expect( view.bindTemplate.if ).to.be.a( 'function' );
 
-			view.register( region, true );
-			view.register( region, true, true );
+			const binding = view.bindTemplate.to( 'a' );
 
-			sinon.assert.notCalled( spy );
+			expect( binding.observable ).to.equal( view );
+			expect( binding.emitter ).to.equal( view );
 		} );
 	} );
 
@@ -240,42 +197,69 @@ describe( 'View', () => {
 		} );
 	} );
 
-	describe( 'destroy', () => {
-		beforeEach( createViewInstanceWithTemplate );
+	describe( 'destroy()', () => {
+		beforeEach( createViewWithChildren );
+
+		it( 'should return a promise', () => {
+			expect( view.destroy() ).to.be.instanceof( Promise );
+		} );
+
+		it( 'should set basic properties null', () => {
+			return view.destroy().then( () => {
+				expect( view.element ).to.be.null;
+				expect( view.template ).to.be.null;
+				expect( view.locale ).to.be.null;
+				expect( view.t ).to.be.null;
+
+				expect( view._unboundChildren ).to.be.null;
+				expect( view._viewCollections ).to.be.null;
+			} );
+		} );
+
+		it( 'clears #_unboundChildren', () => {
+			const cached = view._unboundChildren;
+
+			view.addChild( new View(), new View() );
+			expect( cached ).to.have.length.above( 1 );
 
-		it( 'should destroy the view', () => {
-			view.destroy();
+			return view.destroy().then( () => {
+				expect( cached ).to.have.length( 0 );
+			} );
+		} );
+
+		it( 'clears #_viewCollections', () => {
+			const cached = view._viewCollections;
+
+			expect( cached ).to.have.length( 1 );
 
-			expect( view.model ).to.be.null;
-			expect( view.regions ).to.be.null;
-			expect( view.template ).to.be.null;
-			expect( view.locale ).to.be.null;
-			expect( view.t ).to.be.null;
+			return view.destroy().then( () => {
+				expect( cached ).to.have.length( 0 );
+			} );
 		} );
 
 		it( 'detaches the element from DOM', () => {
 			const elRef = view.element;
 
 			document.createElement( 'div' ).appendChild( view.element );
+			expect( elRef.parentNode ).to.be.not.null;
 
-			view.destroy();
-
-			expect( elRef.parentNode ).to.be.null;
+			return view.destroy().then( () => {
+				expect( elRef.parentNode ).to.be.null;
+			} );
 		} );
 
-		it( 'destroys child regions', () => {
-			const region = new Region( 'x' );
-			const spy = testUtils.sinon.spy( region, 'destroy' );
-			const regionsRef = view.regions;
-			const regionViewsRef = region.views;
+		it( 'calls destroy() on all view#_viewCollections', () => {
+			const collectionA = view.createCollection();
+			const collectionB = view.createCollection();
 
-			view.register( region, true );
-			view.regions.get( 'x' ).views.add( new View() );
-			view.destroy();
+			const spyA = testUtils.sinon.spy( collectionA, 'destroy' );
+			const spyB = testUtils.sinon.spy( collectionB, 'destroy' );
 
-			expect( regionsRef.length ).to.equal( 0 );
-			expect( regionViewsRef.length ).to.equal( 0 );
-			expect( spy.calledOnce ).to.be.true;
+			return view.destroy().then( () => {
+				sinon.assert.calledOnce( spyA );
+				sinon.assert.calledOnce( spyB );
+				sinon.assert.callOrder( spyA, spyB );
+			} );
 		} );
 
 		it( 'destroy a template–less view', () => {
@@ -293,7 +277,7 @@ function createViewInstanceWithTemplate() {
 	setTestViewInstance();
 }
 
-function setTestViewClass( templateDef, regionsFn ) {
+function setTestViewClass( templateDef ) {
 	TestView = class V extends View {
 		constructor() {
 			super();
@@ -301,10 +285,6 @@ function setTestViewClass( templateDef, regionsFn ) {
 			if ( templateDef ) {
 				this.template = new Template( templateDef );
 			}
-
-			if ( templateDef && regionsFn ) {
-				regionsFn.call( this );
-			}
 		}
 	};
 }
@@ -316,3 +296,63 @@ function setTestViewInstance() {
 		document.body.appendChild( view.element );
 	}
 }
+
+function createViewWithChildren() {
+	class ChildView extends View {
+		constructor() {
+			super();
+
+			this.template = new Template( {
+				tag: 'span'
+			} );
+		}
+	}
+
+	class ChildViewA extends ChildView {
+		init() {
+			const promise = new Promise( resolve => {
+				setTimeout( resolve, 50 );
+			} );
+
+			return super.init().then( promise );
+		}
+
+		destroy() {
+			const promise = new Promise( resolve => {
+				setTimeout( resolve, 10 );
+			} );
+
+			return super.destroy().then( promise );
+		}
+	}
+
+	class ChildViewB extends ChildView {
+		init() {
+			const promise = new Promise( resolve => {
+				setTimeout( resolve, 10 );
+			} );
+
+			return super.init().then( promise );
+		}
+
+		destroy() {
+			const promise = new Promise( resolve => {
+				setTimeout( resolve, 50 );
+			} );
+
+			return super.destroy().then( promise );
+		}
+	}
+
+	childA = new ChildViewA();
+	childB = new ChildViewB();
+
+	setTestViewClass( {
+		tag: 'p',
+		children: [ childA, childB ]
+	} );
+
+	setTestViewInstance();
+
+	view.addChild( childA, childB );
+}

+ 520 - 0
packages/ckeditor5-ui/tests/viewcollection.js

@@ -0,0 +1,520 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+/* bender-tags: ui */
+
+import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
+import Collection from 'ckeditor5/utils/collection.js';
+import testUtils from 'tests/core/_utils/utils.js';
+import View from 'ckeditor5/ui/view.js';
+import ViewCollection from 'ckeditor5/ui/viewcollection.js';
+import Template from 'ckeditor5/ui/template.js';
+import normalizeHtml from 'tests/utils/_utils/normalizehtml.js';
+
+let collection;
+
+testUtils.createSinonSandbox();
+
+describe( 'ViewCollection', () => {
+	beforeEach( createTestCollection );
+
+	describe( 'constructor()', () => {
+		it( 'sets basic properties and attributes', () => {
+			expect( collection.locale ).to.be.undefined;
+			expect( collection.ready ).to.be.false;
+			expect( collection._parentElement ).to.be.null;
+			expect( collection._boundItemsToViewsMap ).to.be.instanceOf( Map );
+		} );
+
+		it( 'accepts locale and defines the locale property', () => {
+			const locale = { t() {} };
+
+			expect( new ViewCollection( locale ).locale ).to.equal( locale );
+		} );
+
+		it( 'manages view#element of the children in DOM', () => {
+			const parentElement = document.createElement( 'p' );
+			collection.setParent( parentElement );
+
+			const viewA = new View();
+
+			expect( () => {
+				collection.add( viewA );
+				collection.remove( viewA );
+			} ).to.not.throw();
+
+			expect( () => {
+				collection.ready = true;
+				collection.add( viewA );
+				collection.remove( viewA );
+			} ).to.not.throw();
+
+			viewA.element = document.createElement( 'b' );
+			collection.add( viewA );
+
+			expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p><b></b></p>' );
+
+			const viewB = new View();
+			viewB.element = document.createElement( 'i' );
+
+			collection.add( viewB, 0 );
+			expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p><i></i><b></b></p>' );
+
+			collection.remove( viewA );
+			expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p><i></i></p>' );
+
+			collection.remove( viewB );
+			expect( normalizeHtml( parentElement.outerHTML ) ).to.equal( '<p></p>' );
+		} );
+	} );
+
+	describe( 'init()', () => {
+		it( 'should return a promise', () => {
+			expect( collection.init() ).to.be.instanceof( Promise );
+		} );
+
+		it( 'should throw if already initialized', () => {
+			return collection.init()
+				.then( () => {
+					collection.init();
+
+					throw new Error( 'This should not be executed.' );
+				} )
+				.catch( err => {
+					expect( err ).to.be.instanceof( CKEditorError );
+					expect( err.message ).to.match( /ui-viewcollection-init-reinit/ );
+				} );
+		} );
+
+		it( 'calls #init on all views in the collection', () => {
+			const viewA = new View();
+			const viewB = new View();
+
+			viewA.element = document.createElement( 'a' );
+			viewB.element = document.createElement( 'b' );
+
+			const spyA = testUtils.sinon.spy( viewA, 'init' );
+			const spyB = testUtils.sinon.spy( viewB, 'init' );
+
+			collection.setParent( document.body );
+
+			collection.add( viewA );
+			collection.add( viewB );
+
+			return collection.init().then( () => {
+				sinon.assert.calledOnce( spyA );
+				sinon.assert.calledOnce( spyB );
+				sinon.assert.callOrder( spyA, spyB );
+
+				expect( viewA.element.parentNode ).to.equal( collection._parentElement );
+				expect( viewA.element.nextSibling ).to.equal( viewB.element );
+				expect( collection.ready ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( 'destroy()', () => {
+		it( 'should return a promise', () => {
+			expect( collection.destroy() ).to.be.instanceof( Promise );
+		} );
+
+		it( 'calls #destroy on all views in the collection', () => {
+			const viewA = new View();
+			const viewB = new View();
+
+			const spyA = testUtils.sinon.spy( viewA, 'destroy' );
+			const spyB = testUtils.sinon.spy( viewB, 'destroy' );
+
+			collection.add( viewA );
+			collection.add( viewB );
+
+			return collection.destroy().then( () => {
+				sinon.assert.calledOnce( spyA );
+				sinon.assert.calledOnce( spyB );
+				sinon.assert.callOrder( spyA, spyB );
+			} );
+		} );
+	} );
+
+	describe( 'add', () => {
+		it( 'returns a promise', () => {
+			expect( collection.add( {} ) ).to.be.instanceof( Promise );
+		} );
+
+		it( 'initializes the new view in the collection', () => {
+			let view = new View();
+			let spy = testUtils.sinon.spy( view, 'init' );
+
+			expect( collection.ready ).to.be.false;
+			expect( view.ready ).to.be.false;
+
+			return collection.add( view ).then( () => {
+				expect( collection.ready ).to.be.false;
+				expect( view.ready ).to.be.false;
+
+				sinon.assert.notCalled( spy );
+
+				view = new View();
+				spy = testUtils.sinon.spy( view, 'init' );
+
+				collection.ready = true;
+
+				return collection.add( view ).then( () => {
+					expect( view.ready ).to.be.true;
+
+					sinon.assert.calledOnce( spy );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'setParent', () => {
+		it( 'sets #_parentElement', () => {
+			const el = {};
+			expect( collection._parentElement ).to.be.null;
+
+			collection.setParent( el );
+			expect( collection._parentElement ).to.equal( el );
+		} );
+	} );
+
+	describe( 'bindTo', () => {
+		class ViewClass extends View {
+			constructor( locale, data ) {
+				super( locale );
+
+				this.template = new Template( {
+					tag: 'b'
+				} );
+
+				this.data = data;
+			}
+		}
+
+		it( 'provides "as" interface', () => {
+			const returned = collection.bindTo( {} );
+
+			expect( returned ).to.have.keys( 'as' );
+			expect( returned.as ).to.be.a( 'function' );
+		} );
+
+		describe( 'as', () => {
+			it( 'does not chain', () => {
+				const returned = collection.bindTo( new Collection() ).as( ViewClass );
+
+				expect( returned ).to.be.undefined;
+			} );
+
+			it( 'binds collection as a view factory – initial content', () => {
+				const locale = {};
+				const items = new Collection();
+
+				items.add( { id: '1' } );
+				items.add( { id: '2' } );
+
+				collection = new ViewCollection( locale );
+				collection.bindTo( items ).as( ViewClass );
+
+				expect( collection ).to.have.length( 2 );
+				expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 0 ).locale ).to.equal( locale );
+				expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+			} );
+
+			it( 'binds collection as a view factory – new content', () => {
+				const locale = {};
+				const items = new Collection();
+
+				collection = new ViewCollection( locale );
+				collection.bindTo( items ).as( ViewClass );
+
+				expect( collection ).to.have.length( 0 );
+
+				items.add( { id: '1' } );
+				items.add( { id: '2' } );
+
+				expect( collection ).to.have.length( 2 );
+				expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 0 ).locale ).to.equal( locale );
+				expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+			} );
+
+			it( 'binds collection as a view factory – item removal', () => {
+				const locale = {};
+				const items = new Collection();
+
+				collection = new ViewCollection( locale );
+				collection.bindTo( items ).as( ViewClass );
+
+				expect( collection ).to.have.length( 0 );
+
+				items.add( { id: '1' } );
+				items.add( { id: '2' } );
+
+				expect( collection ).to.have.length( 2 );
+				expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 0 ).locale ).to.equal( locale );
+				expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+
+				items.remove( 1 );
+				expect( collection.get( 0 ).data ).to.equal( items.get( 0 ) );
+
+				items.remove( 0 );
+				expect( collection ).to.have.length( 0 );
+			} );
+
+			it( 'binds collection as a view factory – custom factory', () => {
+				const locale = {};
+				const items = new Collection();
+
+				collection = new ViewCollection( locale );
+				collection.bindTo( items ).as( ( item ) => {
+					return new ViewClass( locale, item );
+				} );
+
+				expect( collection ).to.have.length( 0 );
+
+				items.add( { id: '1' } );
+				items.add( { id: '2' } );
+
+				expect( collection ).to.have.length( 2 );
+				expect( collection.get( 0 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 1 ) ).to.be.instanceOf( ViewClass );
+				expect( collection.get( 0 ).locale ).to.equal( locale );
+				expect( collection.get( 1 ).data ).to.equal( items.get( 1 ) );
+			} );
+		} );
+	} );
+
+	describe( 'delegate', () => {
+		it( 'should throw when event names are not strings', () => {
+			expect( () => {
+				collection.delegate();
+			} ).to.throw( CKEditorError, /ui-viewcollection-delegate-wrong-events/ );
+
+			expect( () => {
+				collection.delegate( new Date() );
+			} ).to.throw( CKEditorError, /ui-viewcollection-delegate-wrong-events/ );
+
+			expect( () => {
+				collection.delegate( 'color', new Date() );
+			} ).to.throw( CKEditorError, /ui-viewcollection-delegate-wrong-events/ );
+		} );
+
+		it( 'returns object', () => {
+			expect( collection.delegate( 'foo' ) ).to.be.an( 'object' );
+		} );
+
+		it( 'provides "to" interface', () => {
+			const delegate = collection.delegate( 'foo' );
+
+			expect( delegate ).to.have.keys( 'to' );
+			expect( delegate.to ).to.be.a( 'function' );
+		} );
+
+		describe( 'to', () => {
+			it( 'does not chain', () => {
+				const returned = collection.delegate( 'foo' ).to( {} );
+
+				expect( returned ).to.be.undefined;
+			} );
+
+			it( 'forwards an event to another observable – existing view', ( done ) => {
+				const target = new View();
+				const view = new View();
+
+				collection.add( view );
+				collection.delegate( 'foo' ).to( target );
+
+				target.on( 'foo', ( ...args ) => {
+					assertDelegated( args, {
+						expectedName: 'foo',
+						expectedSource: view,
+						expectedPath: [ view, target ],
+						expectedData: []
+					} );
+
+					done();
+				} );
+
+				view.fire( 'foo' );
+			} );
+
+			it( 'forwards an event to another observable – new view', ( done ) => {
+				const target = new View();
+				const view = new View();
+
+				collection.delegate( 'foo' ).to( target );
+				collection.add( view );
+
+				target.on( 'foo', ( ...args ) => {
+					assertDelegated( args, {
+						expectedName: 'foo',
+						expectedSource: view,
+						expectedPath: [ view, target ],
+						expectedData: []
+					} );
+
+					done();
+				} );
+
+				view.fire( 'foo' );
+			} );
+
+			it( 'forwards multiple events to another observable', () => {
+				const target = new View();
+				const viewA = new View();
+				const viewB = new View();
+				const viewC = new View();
+				const spyFoo = sinon.spy();
+				const spyBar = sinon.spy();
+				const spyBaz = sinon.spy();
+
+				collection.delegate( 'foo', 'bar', 'baz' ).to( target );
+				collection.add( viewA );
+				collection.add( viewB );
+				collection.add( viewC );
+
+				target.on( 'foo', spyFoo );
+				target.on( 'bar', spyBar );
+				target.on( 'baz', spyBaz );
+
+				viewA.fire( 'foo' );
+
+				sinon.assert.calledOnce( spyFoo );
+				sinon.assert.notCalled( spyBar );
+				sinon.assert.notCalled( spyBaz );
+
+				assertDelegated( spyFoo.args[ 0 ], {
+					expectedName: 'foo',
+					expectedSource: viewA,
+					expectedPath: [ viewA, target ],
+					expectedData: []
+				} );
+
+				viewB.fire( 'bar' );
+
+				sinon.assert.calledOnce( spyFoo );
+				sinon.assert.calledOnce( spyBar );
+				sinon.assert.notCalled( spyBaz );
+
+				assertDelegated( spyBar.args[ 0 ], {
+					expectedName: 'bar',
+					expectedSource: viewB,
+					expectedPath: [ viewB, target ],
+					expectedData: []
+				} );
+
+				viewC.fire( 'baz' );
+
+				sinon.assert.calledOnce( spyFoo );
+				sinon.assert.calledOnce( spyBar );
+				sinon.assert.calledOnce( spyBaz );
+
+				assertDelegated( spyBaz.args[ 0 ], {
+					expectedName: 'baz',
+					expectedSource: viewC,
+					expectedPath: [ viewC, target ],
+					expectedData: []
+				} );
+
+				viewC.fire( 'not-delegated' );
+
+				sinon.assert.calledOnce( spyFoo );
+				sinon.assert.calledOnce( spyBar );
+				sinon.assert.calledOnce( spyBaz );
+			} );
+
+			it( 'does not forward events which are not supposed to be delegated', () => {
+				const target = new View();
+				const view = new View();
+				const spyFoo = sinon.spy();
+				const spyBar = sinon.spy();
+				const spyBaz = sinon.spy();
+
+				collection.delegate( 'foo', 'bar', 'baz' ).to( target );
+				collection.add( view );
+
+				target.on( 'foo', spyFoo );
+				target.on( 'bar', spyBar );
+				target.on( 'baz', spyBaz );
+
+				view.fire( 'foo' );
+				view.fire( 'bar' );
+				view.fire( 'baz' );
+				view.fire( 'not-delegated' );
+
+				sinon.assert.callOrder( spyFoo, spyBar, spyBaz );
+				sinon.assert.callCount( spyFoo, 1 );
+				sinon.assert.callCount( spyBar, 1 );
+				sinon.assert.callCount( spyBaz, 1 );
+			} );
+
+			it( 'stops forwarding when view removed from the collection', () => {
+				const target = new View();
+				const view = new View();
+				const spy = sinon.spy();
+
+				collection.delegate( 'foo' ).to( target );
+				target.on( 'foo', spy );
+
+				collection.add( view );
+				view.fire( 'foo' );
+
+				sinon.assert.callCount( spy, 1 );
+
+				collection.remove( 0 );
+				view.fire( 'foo' );
+
+				sinon.assert.callCount( spy, 1 );
+			} );
+
+			it( 'supports deep event delegation', ( done ) => {
+				const target = new View();
+				const viewA = new View();
+				const viewAA = new View();
+				const data = {};
+
+				const deepCollection = viewA.createCollection();
+
+				collection.add( viewA );
+				collection.delegate( 'foo' ).to( target );
+
+				deepCollection.add( viewAA );
+				deepCollection.delegate( 'foo' ).to( viewA );
+
+				target.on( 'foo', ( ...args ) => {
+					assertDelegated( args, {
+						expectedName: 'foo',
+						expectedSource: viewAA,
+						expectedPath: [ viewAA, viewA, target ],
+						expectedData: [ data ]
+					} );
+
+					done();
+				} );
+
+				viewAA.fire( 'foo', data );
+			} );
+		} );
+	} );
+} );
+
+function createTestCollection() {
+	collection = new ViewCollection();
+}
+
+function assertDelegated( evtArgs, { expectedName, expectedSource, expectedPath, expectedData } ) {
+	const evtInfo = evtArgs[ 0 ];
+
+	expect( evtInfo.name ).to.equal( expectedName );
+	expect( evtInfo.source ).to.equal( expectedSource );
+	expect( evtInfo.path ).to.deep.equal( expectedPath );
+	expect( evtArgs.slice( 1 ) ).to.deep.equal( expectedData );
+}