Browse Source

Merge pull request #60 from cksource/ui

Basic UI classes for editor instances.
Piotrek Koszuliński 10 years ago
parent
commit
6cc14b58b5

+ 247 - 0
packages/ckeditor5-engine/src/ui/domemittermixin.js

@@ -0,0 +1,247 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+/**
+ * Mixin that injects the DOM events API into its host. It provides the API
+ * compatible with {@link EmitterMixin}.
+ *
+ *                               listenTo( click, ... )
+ *                 +-----------------------------------------+
+ *                 |              stopListening( ... )       |
+ *  +----------------------------+                           |             addEventListener( click, ... )
+ *  | Host                       |                           |   +---------------------------------------------+
+ *  +----------------------------+                           |   |       removeEventListener( click, ... )     |
+ *  | _listeningTo: {            |                +----------v-------------+                                   |
+ *  |   UID: {                   |                | ProxyEmitter           |                                   |
+ *  |     emitter: ProxyEmitter, |                +------------------------+                      +------------v----------+
+ *  |     callbacks: {           |                | events: {              |                      | Node (HTMLElement)    |
+ *  |       click: [ callbacks ] |                |   click: [ callbacks ] |                      +-----------------------+
+ *  |     }                      |                | },                     |                      | data-cke-expando: UID |
+ *  |   }                        |                | _domNode: Node,        |                      +-----------------------+
+ *  | }                          |                | _domListeners: {},     |                                   |
+ *  | +------------------------+ |                | _emitterId: UID        |                                   |
+ *  | | DOMEmitterMixin        | |                +--------------^---------+                                   |
+ *  | +------------------------+ |                           |   |                                             |
+ *  +--------------^-------------+                           |   +---------------------------------------------+
+ *                 |                                         |                  click (DOM Event)
+ *                 +-----------------------------------------+
+ *                             fire( click, DOM Event )
+ *
+ * @class DOMEmitterMixin
+ * @extends EmitterMixin
+ * @singleton
+ */
+
+CKEDITOR.define( [ 'emittermixin', 'utils', 'log' ], function( EmitterMixin, utils, log ) {
+	var DOMEmitterMixin = {
+		/**
+		 * Registers a callback function to be executed when an event is fired in a specific Emitter or DOM Node.
+		 * It is backwards compatible with {@link EmitterMixin#listenTo}.
+		 *
+		 * @param {Emitter|Node} emitter The object that fires the event.
+		 * @param {String} event The name of the event.
+		 * @param {Function} callback The function to be called on event.
+		 * @param {Object} [ctx] The object that represents `this` in the callback. Defaults to `emitter`.
+		 * @param {Number} [priority=10] The priority of this callback in relation to other callbacks to that same event.
+		 * Lower values are called first.
+		 */
+		listenTo() {
+			var args = Array.prototype.slice.call( arguments );
+			var emitter = args[ 0 ];
+
+			// Check if emitter is an instance of DOM Node. If so, replace the argument with
+			// corresponding ProxyEmitter (or create one if not existing).
+			if ( emitter instanceof Node ) {
+				args[ 0 ] = this._getProxyEmitter( emitter ) || new ProxyEmitter( emitter );
+			}
+
+			// Execute parent class method with Emitter (or ProxyEmitter) instance.
+			EmitterMixin.listenTo.apply( this, args );
+		},
+
+		/**
+		 * Stops listening for events. It can be used at different levels:
+		 * It is backwards compatible with {@link EmitterMixin#listenTo}.
+		 *
+		 * * To stop listening to a specific callback.
+		 * * To stop listening to a specific event.
+		 * * To stop listening to all events fired by a specific object.
+		 * * To stop listening to all events fired by all object.
+		 *
+		 * @param {Emitter|Node} [emitter] The object to stop listening to. If omitted, stops it for all objects.
+		 * @param {String} [event] (Requires the `emitter`) The name of the event to stop listening to. If omitted, stops it
+		 * for all events from `emitter`.
+		 * @param {Function} [callback] (Requires the `event`) The function to be removed from the call list for the given
+		 * `event`.
+		 */
+		stopListening() {
+			var args = Array.prototype.slice.call( arguments );
+			var emitter = args[ 0 ];
+
+			// Check if emitter is an instance of DOM Node. If so, replace the argument with corresponding ProxyEmitter.
+			if ( emitter instanceof Node ) {
+				var proxy = this._getProxyEmitter( emitter );
+
+				if ( proxy ) {
+					args[ 0 ] = proxy;
+				} else {
+					log.error(
+						'domemittermixin-stoplistening: Stopped listening on a DOM Node that has no emitter or emitter is gone.',
+						emitter
+					);
+				}
+			}
+
+			// Execute parent class method with Emitter (or ProxyEmitter) instance.
+			EmitterMixin.stopListening.apply( this, args );
+		},
+
+		/**
+		 * Retrieves ProxyEmitter instance for given DOM Node residing in this Host.
+		 *
+		 * @param {Node} node DOM Node of the ProxyEmitter.
+		 * @return {ProxyEmitter} ProxyEmitter instance or null.
+		 */
+		_getProxyEmitter( node ) {
+			var proxy, emitters, emitterInfo;
+
+			// Get node UID. It allows finding Proxy Emitter for this DOM Node.
+			var uid = getNodeUID( node );
+
+			// Find existing Proxy Emitter for this DOM Node among emitters.
+			if ( ( emitters = this._listeningTo ) ) {
+				if ( ( emitterInfo = emitters[ uid ] ) ) {
+					proxy = emitterInfo.emitter;
+				}
+			}
+
+			return proxy || null;
+		}
+	};
+
+	/**
+	 * Creates a ProxyEmitter instance. Such an instance is a bridge between a DOM Node firing events
+	 * and any Host listening to them. It is backwards compatible with {@link EmitterMixin#on}.
+	 *
+	 * @class DOMEmitterMixin
+	 * @mixins EmitterMixin
+	 * @param {Node} node DOM Node that fires events.
+	 * @returns {Object} ProxyEmitter instance bound to the DOM Node.
+	 */
+	class ProxyEmitter {
+		constructor( node ) {
+			// Set emitter ID to match DOM Node "expando" property.
+			this._emitterId = getNodeUID( node );
+
+			// Remember the DOM Node this ProxyEmitter is bound to.
+			this._domNode = node;
+		}
+	}
+
+	utils.extend( ProxyEmitter.prototype, EmitterMixin, {
+		/**
+		 * Collection of native DOM listeners.
+		 *
+		 * @property {Object} _domListeners
+		 */
+
+		/**
+		 * Registers a callback function to be executed when an event is fired.
+		 *
+		 * It attaches a native DOM listener to the DOM Node. When fired,
+		 * a corresponding Emitter event will also fire with DOM Event object as an argument.
+		 *
+		 * @param {String} event The name of the event.
+		 * @param {Function} callback The function to be called on event.
+		 * @param {Object} [ctx] The object that represents `this` in the callback. Defaults to the object firing the
+		 * event.
+		 * @param {Number} [priority=10] The priority of this callback in relation to other callbacks to that same event.
+		 * Lower values are called first.
+		 */
+		on( event ) {
+			// Execute parent class method first.
+			EmitterMixin.on.apply( this, arguments );
+
+			// If the DOM Listener for given event already exist it is pointless
+			// to attach another one.
+			if ( this._domListeners && this._domListeners[ event ] ) {
+				return;
+			}
+
+			var domListener = this._createDomListener( event );
+
+			// Attach the native DOM listener to DOM Node.
+			this._domNode.addEventListener( event, domListener );
+
+			if ( !this._domListeners ) {
+				this._domListeners = {};
+			}
+
+			// Store the native DOM listener in this ProxyEmitter. It will be helpful
+			// when stopping listening to the event.
+			this._domListeners[ event ] = domListener;
+		},
+
+		/**
+		 * Stops executing the callback on the given event.
+		 *
+		 * @param {String} event The name of the event.
+		 * @param {Function} callback The function to stop being called.
+		 * @param {Object} [ctx] The context object to be removed, pared with the given callback. To handle cases where
+		 * the same callback is used several times with different contexts.
+		 */
+		off( event ) {
+			// Execute parent class method first.
+			EmitterMixin.off.apply( this, arguments );
+
+			var callbacks;
+
+			// Remove native DOM listeners which are orphans. If no callbacks
+			// are awaiting given event, detach native DOM listener from DOM Node.
+			// See: {@link on}.
+			if ( !( callbacks = this._events[ event ] ) || !callbacks.length ) {
+				this._domListeners[ event ].removeListener();
+			}
+		},
+
+		/**
+		 * Create a native DOM listener callback. When the native DOM event
+		 * is fired it will fire corresponding event on this ProxyEmitter.
+		 * Note: A native DOM Event is passed as an argument.
+		 *
+		 * @param {String} event
+		 * @returns {Function} The DOM listener callback.
+		 */
+		_createDomListener( event ) {
+			var domListener = domEvt => {
+				this.fire( event, domEvt );
+			};
+
+			// Supply the DOM listener callback with a function that will help
+			// detach it from the DOM Node, when it is no longer necessary.
+			// See: {@link off}.
+			domListener.removeListener = () => {
+				this._domNode.removeEventListener( event, domListener );
+				delete this._domListeners[ event ];
+			};
+
+			return domListener;
+		}
+	} );
+
+	return DOMEmitterMixin;
+
+	/**
+	 * Gets an unique DOM Node identifier. The identifier will be set if not defined.
+	 *
+	 * @param {Node} node
+	 * @return {Number} UID for given DOM Node.
+	 */
+	function getNodeUID( node ) {
+		return node[ 'data-ck-expando' ] || ( node[ 'data-ck-expando' ] = utils.uid() );
+	}
+} );

+ 61 - 0
packages/ckeditor5-engine/src/ui/region.js

@@ -0,0 +1,61 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+/**
+ * Basic Region class.
+ *
+ * @class Region
+ * @extends Model
+ */
+
+CKEDITOR.define( [ 'collection', 'model' ], function( Collection, Model ) {
+	class Region extends Model {
+		/**
+		 * Creates an instance of the {@link Region} class.
+		 *
+		 * @param {String} name The name of the Region.
+		 * @param {HTMLElement} [el] The element used for this region.
+		 * @constructor
+		 */
+		constructor( name, el ) {
+			super();
+
+			/**
+			 * The name of the region.
+			 */
+			this.name = name;
+
+			/**
+			 * The element of the region.
+			 */
+			this.el = el;
+
+			/**
+			 * Views which belong to the region.
+			 */
+			this.views = new Collection();
+
+			this.views.on( 'add', ( evt, view ) => this.el && this.el.appendChild( view.el ) );
+			this.views.on( 'remove', ( evt, view ) => view.el.remove() );
+		}
+
+		/**
+		 * Destroys the Region instance.
+		 */
+		destroy() {
+			// 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.el = null;
+
+			// Remove and destroy views.
+			this.views.forEach( v => this.views.remove( v ).destroy() );
+		}
+	}
+
+	return Region;
+} );

+ 177 - 0
packages/ckeditor5-engine/src/ui/template.js

@@ -0,0 +1,177 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+'use strict';
+
+/**
+ * Basic Template class.
+ *
+ * @class Template
+ */
+
+CKEDITOR.define( function() {
+	class Template {
+		/**
+		 * Creates an instance of the {@link Template} class.
+		 *
+		 * @param {TemplateDefinition} def The definition of the template.
+		 * @constructor
+		 */
+		constructor( def ) {
+			/**
+			 * Definition of this template.
+			 *
+			 * @property {TemplateDefinition}
+			 */
+			this.def = def;
+		}
+
+		/**
+		 * Renders HTMLElement using {@link #def}.
+		 *
+		 * @returns {HTMLElement}
+		 */
+		render() {
+			return renderElement( this.def );
+		}
+	}
+
+	function getTextUpdater() {
+		return ( el, value ) => el.innerHTML = value;
+	}
+
+	function getAttributeUpdater( attr ) {
+		return ( el, value ) => el.setAttribute( attr, value );
+	}
+
+	function renderElement( def ) {
+		if ( !def ) {
+			return null;
+		}
+
+		var el = document.createElement( def.tag );
+
+		// Set the text first.
+		renderElementText( def, el );
+
+		// Set attributes.
+		renderElementAttributes( def, el );
+
+		// Invoke children recursively.
+		renderElementChildren( def, el );
+
+		// Activate DOM binding for event listeners.
+		activateElementListeners( def, el );
+
+		return el;
+	}
+
+	function renderElementText( def, el ) {
+		if ( def.text ) {
+			if ( typeof def.text == 'function' ) {
+				def.text( el, getTextUpdater() );
+			} else {
+				el.innerHTML = def.text;
+			}
+		}
+	}
+
+	function renderElementAttributes( def, el ) {
+		var value;
+		var attr;
+
+		for ( attr in def.attrs ) {
+			value = def.attrs[ attr ];
+
+			// Attribute bound directly to the model.
+			if ( typeof value == 'function' ) {
+				value( el, getAttributeUpdater( attr ) );
+			}
+
+			// Explicit attribute definition (string).
+			else {
+				// Attribute can be an array, i.e. classes.
+				if ( Array.isArray( value ) ) {
+					value = value.join( ' ' );
+				}
+
+				el.setAttribute( attr, value );
+			}
+		}
+	}
+
+	function renderElementChildren( def, el ) {
+		var child;
+
+		if ( def.children ) {
+			for ( child of def.children ) {
+				el.appendChild( renderElement( child ) );
+			}
+		}
+	}
+
+	function activateElementListeners( def, el ) {
+		if ( def.on ) {
+			for ( var l in def.on ) {
+				var domEvtDef = l.split( '@' );
+				var name, selector;
+
+				if ( domEvtDef.length == 2 ) {
+					name = domEvtDef[ 0 ];
+					selector = domEvtDef[ 1 ];
+				} else {
+					name = l;
+					selector = null;
+				}
+
+				if ( Array.isArray( def.on[ l ] ) ) {
+					def.on[ l ].map( i => i( el, name, selector ) );
+				} else {
+					def.on[ l ]( el, name, selector );
+				}
+			}
+		}
+	}
+
+	return Template;
+} );
+
+/**
+ * The virtual class representing an argument of the {@link Template} constructor.
+ *
+ *		{
+ *			tag: 'p',
+ *			children: [
+ *				{
+ *					tag: 'span',
+ *					attrs: { ... },
+ *					on: { ... }
+ *				},
+ *				{
+ *					...
+ *				},
+ *				...
+ *			],
+ *			attrs: {
+ *				'class': [ 'a', 'b' ],
+ *				id: 'c',
+ *				style: callback,
+ *				...
+ *			},
+ *			on: {
+ *				w: 'a'
+ *				x: [ 'b', 'c', callback ],
+ *				'y@selector': 'd',
+ *				'z@selector': [ 'e', 'f', callback ],
+ *				...
+ *			},
+ *			text: 'abc'
+ *		}
+ *
+ * @abstract
+ * @class TemplateDefinition
+ */

+ 265 - 0
packages/ckeditor5-engine/src/ui/view.js

@@ -0,0 +1,265 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+/**
+ * Basic View class.
+ *
+ * @class View
+ * @extends Model
+ * @mixins DOMEmitterMixin
+ */
+
+CKEDITOR.define( [
+	'namedcollection',
+	'model',
+	'ui/template',
+	'ckeditorerror',
+	'ui/domemittermixin',
+	'utils'
+], function( NamedCollection, Model, Template, CKEditorError, DOMEmitterMixin, utils ) {
+	class View extends Model {
+		/**
+		 * Creates an instance of the {@link View} class.
+		 *
+		 * @param {Model} model (View)Model of this View.
+		 * @constructor
+		 */
+		constructor( model ) {
+			super();
+
+			/**
+			 * Model of this view.
+			 */
+			this.model = new Model( model );
+
+			/**
+			 * Regions which belong to this view.
+			 */
+			this.regions = new NamedCollection();
+
+			/**
+			 * @property {HTMLElement} _el
+			 */
+
+			/**
+			 * @property {Template} _template
+			 */
+
+			/**
+			 * @property {TemplateDefinition} template
+			 */
+		}
+
+		/**
+		 * Element of this view. The element is rendered on first reference.
+		 *
+		 * @property el
+		 */
+		get el() {
+			return this._el || this.render();
+		}
+
+		/**
+		 * Binds a `property` of View's model so the DOM of the View is updated when the `property`
+		 * changes. It returns a function which, once called in the context of a DOM element,
+		 * attaches a listener to the model which, in turn, brings changes to DOM.
+		 *
+		 * @param {String} property Property name in the model to be observed.
+		 * @param {Function} [callback] Callback function executed on property change in model.
+		 * If not specified, a default DOM `domUpdater` supplied by the template is used.
+		 */
+		bind( property, callback ) {
+			/**
+			 * Attaches a listener to View's model, which updates DOM when the model's property
+			 * changes. DOM is either updated by the `domUpdater` function supplied by the template
+			 * (like attribute changer or `innerHTML` setter) or custom `callback` passed to {@link #bind}.
+			 *
+			 * This function is called by {@link Template#render}.
+			 *
+			 * @param {HTMLElement} el DOM element to be updated when `property` in model changes.
+			 * @param {Function} domUpdater A function provided by the template which updates corresponding
+			 * DOM.
+			 */
+			return ( el, domUpdater ) => {
+				// TODO: Use ES6 default arguments syntax.
+				callback = callback || domUpdater;
+
+				// Execute callback when the property changes.
+				this.listenTo( this.model, 'change:' + property, onModelChange );
+
+				// Set the initial state of the view.
+				onModelChange( null, this.model[ property ] );
+
+				function onModelChange( evt, value ) {
+					var processedValue = callback( el, value );
+
+					if ( typeof processedValue != 'undefined' ) {
+						domUpdater( el, processedValue );
+					}
+				}
+			};
+		}
+
+		/**
+		 * Renders View's {@link el} using {@link Template} instance.
+		 *
+		 * @returns {HTMLElement} A root element of the View ({@link el}).
+		 */
+		render() {
+			if ( !this.template ) {
+				/**
+				 * This View implements no template to render.
+				 *
+				 * @error ui-view-notemplate
+				 * @param {View} view
+				 */
+				throw new CKEditorError(
+					'ui-view-notemplate: This View implements no template to render.',
+					{ view: this }
+				);
+			}
+
+			// Prepare pre–defined listeners.
+			this.prepareListeners();
+
+			this._template = new Template( this.template );
+
+			return ( this._el = this._template.render() );
+		}
+
+		/**
+		 * Destroys the View.
+		 */
+		destroy() {
+			// Drop the reference to the model.
+			this.model = null;
+
+			// Remove View's element from DOM.
+			if ( this.template ) {
+				this.el.remove();
+			}
+
+			// Remove and destroy regions.
+			this.regions.forEach( r => this.regions.remove( r ).destroy() );
+
+			// Remove all listeners related to this view.
+			this.stopListening();
+		}
+
+		/**
+		 * Iterates over all "on" properties in {@link template} and replaces
+		 * listener definitions with functions which, once executed in a context of
+		 * a DOM element, will attach native DOM listeners to elements.
+		 *
+		 * The execution is performed by {@link Template} class.
+		 */
+		prepareListeners() {
+			if ( this.template ) {
+				this._prepareElementListeners( this.template );
+			}
+		}
+
+		/**
+		 * For a given event name or callback, returns a function which,
+		 * once executed in a context of an element, attaches native DOM listener
+		 * to the element. The listener executes given callback or fires View's event
+		 * of given name.
+		 *
+		 * @param {String|Function} evtNameOrCallback Event name to be fired on View or callback to execute.
+		 * @returns {Function} A function to be executed in the context of an element.
+		 */
+		_getDOMListenerAttacher( evtNameOrCallback ) {
+			/**
+			 * Attaches a native DOM listener to given element. The listener executes the
+			 * callback or fires View's event.
+			 *
+			 * Note: If the selector is supplied, it narrows the scope to relevant targets only.
+			 * So instead of
+			 *
+			 *     children: [
+			 *         { tag: 'span', on: { click: 'foo' } }
+			 *         { tag: 'span', on: { click: 'foo' } }
+			 *     ]
+			 *
+			 * a single, more efficient listener can be attached that uses **event delegation**:
+			 *
+			 *     children: [
+			 *     	   { tag: 'span' }
+			 *     	   { tag: 'span' }
+			 *     ],
+			 *     on: {
+			 *     	   'click@span': 'foo',
+			 *     }
+			 *
+			 * @param {HTMLElement} el Element, to which the native DOM Event listener is attached.
+			 * @param {String} domEventName The name of native DOM Event.
+			 * @param {String} [selector] If provided, the selector narrows the scope to relevant targets only.
+			 */
+			return ( el, domEvtName, selector ) => {
+				// Use View's listenTo, so the listener is detached, when the View dies.
+				this.listenTo( el, domEvtName, ( evt, domEvt ) => {
+					if ( !selector || domEvt.target.matches( selector ) ) {
+						if ( typeof evtNameOrCallback == 'function' ) {
+							evtNameOrCallback( domEvt );
+						} else {
+							this.fire( evtNameOrCallback, domEvt );
+						}
+					}
+				} );
+			};
+		}
+
+		/**
+		 * Iterates over "on" property in {@link template} definition to recursively
+		 * replace each listener declaration with a function which, once executed in a context
+		 * of an element, attaches native DOM listener to the element.
+		 *
+		 * @param {TemplateDefinition} def Template definition.
+		 */
+		_prepareElementListeners( def ) {
+			let on = def.on;
+
+			if ( on ) {
+				let domEvtName, evtNameOrCallback;
+
+				for ( domEvtName in on ) {
+					evtNameOrCallback = on[ domEvtName ];
+
+					// Listeners allow definition with an array:
+					//
+					//    on: {
+					//        'DOMEventName@selector': [ 'event1', callback ],
+					//        'DOMEventName': [ callback, 'event2', 'event3' ]
+					//        ...
+					//    }
+					if ( Array.isArray( evtNameOrCallback ) ) {
+						on[ domEvtName ] = on[ domEvtName ].map( this._getDOMListenerAttacher, this );
+					}
+					// Listeners allow definition with a string containing event name:
+					//
+					//    on: {
+					//       'DOMEventName@selector': 'event1',
+					//       'DOMEventName': 'event2'
+					//       ...
+					//    }
+					else {
+						on[ domEvtName ] = this._getDOMListenerAttacher( evtNameOrCallback );
+					}
+				}
+			}
+
+			// Repeat recursively for the children.
+			if ( def.children ) {
+				def.children.map( this._prepareElementListeners, this );
+			}
+		}
+	}
+
+	utils.extend( View.prototype, DOMEmitterMixin );
+
+	return View;
+} );

+ 353 - 0
packages/ckeditor5-engine/tests/ui/domemittermixin.js

@@ -0,0 +1,353 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document, Event, MouseEvent */
+/* bender-tags: core, ui */
+
+'use strict';
+
+var modules = bender.amd.require( 'utils', 'ui/domemittermixin', 'emittermixin' );
+var emitter, domEmitter, node;
+
+bender.tools.createSinonSandbox();
+
+var getEmitterInstance = () => modules.utils.extend( {}, modules.emittermixin );
+var getDOMEmitterInstance = () => modules.utils.extend( {}, modules[ 'ui/domemittermixin' ] );
+var getDOMNodeInstance = () => document.createElement( 'div' );
+
+function updateEmitterInstance() {
+	emitter = getEmitterInstance();
+}
+
+function updateDOMEmitterInstance() {
+	domEmitter = getDOMEmitterInstance();
+}
+
+function updateDOMNodeInstance() {
+	node = getDOMNodeInstance();
+}
+
+beforeEach( updateEmitterInstance );
+beforeEach( updateDOMEmitterInstance );
+beforeEach( updateDOMNodeInstance );
+
+describe( 'listenTo', function() {
+	it( 'should listen to EmitterMixin events', function() {
+		var spy = bender.sinon.spy();
+
+		domEmitter.listenTo( emitter, 'test', spy );
+		emitter.fire( 'test' );
+
+		sinon.assert.calledOnce( spy );
+	} );
+
+	it( 'should listen to native DOM events', function() {
+		var spy = bender.sinon.spy();
+
+		domEmitter.listenTo( node, 'test', spy );
+
+		var event = new Event( 'test' );
+		node.dispatchEvent( event );
+
+		sinon.assert.calledOnce( spy );
+	} );
+} );
+
+describe( 'stopListening', function() {
+	it( 'should stop listening to a specific event callback', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+
+		domEmitter.listenTo( node, 'event1', spy1 );
+		domEmitter.listenTo( node, 'event2', spy2 );
+
+		node.dispatchEvent( new Event( 'event1' ) );
+		node.dispatchEvent( new Event( 'event2' ) );
+
+		domEmitter.stopListening( node, 'event1', spy1 );
+
+		node.dispatchEvent( new Event( 'event1' ) );
+		node.dispatchEvent( new Event( 'event2' ) );
+
+		sinon.assert.calledOnce( spy1 );
+		sinon.assert.calledTwice( spy2 );
+	} );
+
+	it( 'should stop listening to an specific event', function() {
+		var spy1a = bender.sinon.spy();
+		var spy1b = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+
+		domEmitter.listenTo( node, 'event1', spy1a );
+		domEmitter.listenTo( node, 'event1', spy1b );
+		domEmitter.listenTo( node, 'event2', spy2 );
+
+		node.dispatchEvent( new Event( 'event1' ) );
+		node.dispatchEvent( new Event( 'event2' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy1b );
+		sinon.assert.calledOnce( spy2 );
+
+		domEmitter.stopListening( node, 'event1' );
+
+		node.dispatchEvent( new Event( 'event1' ) );
+		node.dispatchEvent( new Event( 'event2' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy1b );
+		sinon.assert.calledTwice( spy2 );
+	} );
+
+	it( 'should stop listening to all events from a specific node', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+
+		domEmitter.listenTo( node, 'event1', spy1 );
+		domEmitter.listenTo( node, 'event2', spy2 );
+
+		node.dispatchEvent( new Event( 'event1' ) );
+		node.dispatchEvent( new Event( 'event2' ) );
+
+		domEmitter.stopListening( node );
+
+		node.dispatchEvent( new Event( 'event1' ) );
+		node.dispatchEvent( new Event( 'event2' ) );
+
+		sinon.assert.calledOnce( spy1 );
+		sinon.assert.calledOnce( spy2 );
+	} );
+
+	it( 'should stop listening to everything', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+
+		var node1 = getDOMNodeInstance();
+		var node2 = getDOMNodeInstance();
+
+		domEmitter.listenTo( node1, 'event1', spy1 );
+		domEmitter.listenTo( node2, 'event2', spy2 );
+
+		expect( domEmitter ).to.have.property( '_listeningTo' );
+
+		node1.dispatchEvent( new Event( 'event1' ) );
+		node2.dispatchEvent( new Event( 'event2' ) );
+
+		domEmitter.stopListening();
+
+		node1.dispatchEvent( new Event( 'event1' ) );
+		node2.dispatchEvent( new Event( 'event2' ) );
+
+		sinon.assert.calledOnce( spy1 );
+		sinon.assert.calledOnce( spy2 );
+
+		expect( domEmitter ).to.not.have.property( '_listeningTo' );
+	} );
+
+	it( 'should not stop other nodes when a non-listened node is provided', function() {
+		var spy = bender.sinon.spy();
+
+		var node1 = getDOMNodeInstance();
+		var node2 = getDOMNodeInstance();
+
+		domEmitter.listenTo( node1, 'test', spy );
+
+		domEmitter.stopListening( node2 );
+
+		node1.dispatchEvent( new Event( 'test' ) );
+
+		sinon.assert.called( spy );
+	} );
+
+	it( 'should pass DOM Event data to the listener', function() {
+		var spy = bender.sinon.spy();
+
+		var node = getDOMNodeInstance();
+
+		domEmitter.listenTo( node, 'click', spy );
+
+		var mouseEvent = new MouseEvent( 'click', {
+			screenX: 10,
+			screenY: 20
+		} );
+
+		node.dispatchEvent( mouseEvent );
+
+		sinon.assert.calledOnce( spy );
+		expect( spy.args[ 0 ][ 1 ] ).to.be.equal( mouseEvent );
+	} );
+
+	it( 'should detach native DOM event listener proxy, specific event', function() {
+		var spy1a = bender.sinon.spy();
+		var spy1b = bender.sinon.spy();
+
+		domEmitter.listenTo( node, 'test', spy1a );
+
+		var proxyEmitter = domEmitter._getProxyEmitter( node );
+		var spy2 = bender.sinon.spy( proxyEmitter, 'fire' );
+
+		node.dispatchEvent( new Event( 'test' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy2 );
+
+		domEmitter.stopListening( node, 'test' );
+		node.dispatchEvent( new Event( 'test' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy2 );
+
+		// Attach same event again.
+		domEmitter.listenTo( node, 'test', spy1b );
+		node.dispatchEvent( new Event( 'test' ) );
+
+		expect( proxyEmitter ).to.be.equal( domEmitter._getProxyEmitter( node ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy1b );
+		sinon.assert.calledTwice( spy2 );
+	} );
+
+	it( 'should detach native DOM event listener proxy, specific callback', function() {
+		var spy1a = bender.sinon.spy();
+		var spy1b = bender.sinon.spy();
+		var spy1c = bender.sinon.spy();
+
+		domEmitter.listenTo( node, 'test', spy1a );
+		domEmitter.listenTo( node, 'test', spy1b );
+
+		var proxyEmitter = domEmitter._getProxyEmitter( node );
+		var spy2 = bender.sinon.spy( proxyEmitter, 'fire' );
+
+		node.dispatchEvent( new Event( 'test' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy1b );
+		sinon.assert.calledOnce( spy2 );
+
+		domEmitter.stopListening( node, 'test', spy1a );
+		node.dispatchEvent( new Event( 'test' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledTwice( spy1b );
+		sinon.assert.calledTwice( spy2 );
+
+		domEmitter.stopListening( node, 'test', spy1b );
+		node.dispatchEvent( new Event( 'test' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledTwice( spy1b );
+		sinon.assert.calledTwice( spy2 );
+
+		// Attach same event again.
+		domEmitter.listenTo( node, 'test', spy1c );
+		node.dispatchEvent( new Event( 'test' ) );
+
+		expect( proxyEmitter ).to.be.equal( domEmitter._getProxyEmitter( node ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledTwice( spy1b );
+		sinon.assert.calledOnce( spy1c );
+		sinon.assert.calledThrice( spy2 );
+	} );
+
+	it( 'should detach native DOM event listener proxy, specific emitter', function() {
+		var spy1a = bender.sinon.spy();
+		var spy1b = bender.sinon.spy();
+		var spy1c = bender.sinon.spy();
+		var spy1d = bender.sinon.spy();
+
+		domEmitter.listenTo( node, 'test1', spy1a );
+		domEmitter.listenTo( node, 'test2', spy1b );
+
+		var proxyEmitter = domEmitter._getProxyEmitter( node );
+		var spy2 = bender.sinon.spy( proxyEmitter, 'fire' );
+
+		node.dispatchEvent( new Event( 'test1' ) );
+		node.dispatchEvent( new Event( 'test2' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy1b );
+		sinon.assert.calledTwice( spy2 );
+
+		domEmitter.stopListening( node );
+
+		node.dispatchEvent( new Event( 'test1' ) );
+		node.dispatchEvent( new Event( 'test2' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy1b );
+		sinon.assert.calledTwice( spy2 );
+
+		// Attach same event again.
+		domEmitter.listenTo( node, 'test1', spy1c );
+		domEmitter.listenTo( node, 'test2', spy1d );
+
+		// Old proxy emitter died when stopped listening to the node.
+		var proxyEmitter2 = domEmitter._getProxyEmitter( node );
+		var spy3 = bender.sinon.spy( proxyEmitter2, 'fire' );
+
+		node.dispatchEvent( new Event( 'test1' ) );
+		node.dispatchEvent( new Event( 'test2' ) );
+
+		expect( proxyEmitter ).to.not.be.equal( proxyEmitter2 );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy1b );
+		sinon.assert.calledOnce( spy1c );
+		sinon.assert.calledOnce( spy1d );
+		sinon.assert.calledTwice( spy2 );
+		sinon.assert.calledTwice( spy3 );
+	} );
+
+	it( 'should detach native DOM event listener proxy, everything', function() {
+		var spy1a = bender.sinon.spy();
+		var spy1b = bender.sinon.spy();
+		var spy1c = bender.sinon.spy();
+		var spy1d = bender.sinon.spy();
+
+		domEmitter.listenTo( node, 'test1', spy1a );
+		domEmitter.listenTo( node, 'test2', spy1b );
+
+		var proxyEmitter = domEmitter._getProxyEmitter( node );
+		var spy2 = bender.sinon.spy( proxyEmitter, 'fire' );
+
+		node.dispatchEvent( new Event( 'test1' ) );
+		node.dispatchEvent( new Event( 'test2' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy1b );
+		sinon.assert.calledTwice( spy2 );
+
+		domEmitter.stopListening();
+
+		node.dispatchEvent( new Event( 'test1' ) );
+		node.dispatchEvent( new Event( 'test2' ) );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy1b );
+		sinon.assert.calledTwice( spy2 );
+
+		// Attach same event again.
+		domEmitter.listenTo( node, 'test1', spy1c );
+		domEmitter.listenTo( node, 'test2', spy1d );
+
+		// Old proxy emitter died when stopped listening to the node.
+		var proxyEmitter2 = domEmitter._getProxyEmitter( node );
+		var spy3 = bender.sinon.spy( proxyEmitter2, 'fire' );
+
+		node.dispatchEvent( new Event( 'test1' ) );
+		node.dispatchEvent( new Event( 'test2' ) );
+
+		expect( proxyEmitter ).to.not.be.equal( proxyEmitter2 );
+
+		sinon.assert.calledOnce( spy1a );
+		sinon.assert.calledOnce( spy1b );
+		sinon.assert.calledOnce( spy1c );
+		sinon.assert.calledOnce( spy1d );
+		sinon.assert.calledTwice( spy2 );
+		sinon.assert.calledTwice( spy3 );
+	} );
+} );

+ 115 - 0
packages/ckeditor5-engine/tests/ui/region.js

@@ -0,0 +1,115 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+/* bender-tags: core, ui */
+
+'use strict';
+
+var modules = bender.amd.require( 'ckeditor', 'ui/region', 'ui/view', 'collection' );
+
+bender.tools.createSinonSandbox();
+
+var TestViewA, TestViewB;
+var region, el;
+
+beforeEach( createRegionInstance );
+
+describe( 'constructor', function() {
+	it( 'accepts name and element', function() {
+		expect( region ).to.have.property( 'name', 'foo' );
+		expect( region ).to.have.property( 'el', el );
+	} );
+} );
+
+describe( 'views collection', function() {
+	it( 'is an instance of Collection', function() {
+		var Collection = modules.collection;
+		expect( region.views ).to.be.an.instanceof( Collection );
+	} );
+
+	it( 'updates DOM when adding views', function() {
+		expect( region.el.childNodes.length ).to.be.equal( 0 );
+
+		region.views.add( new TestViewA() );
+		expect( region.el.childNodes.length ).to.be.equal( 1 );
+
+		region.views.add( new TestViewA() );
+		expect( region.el.childNodes.length ).to.be.equal( 2 );
+	} );
+
+	it( 'updates DOM when removing views', function() {
+		var viewA = new TestViewA();
+		var 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', function() {
+	it( 'destroys the region', function() {
+		// Append the region's element to some container.
+		var container = document.createElement( 'div' );
+		container.appendChild( el );
+		expect( el.parentNode ).to.be.equal( container );
+
+		region.destroy();
+
+		// Make sure destruction of the region does affect passed element.
+		expect( el.parentNode ).to.be.equal( container );
+		expect( region.el ).to.be.null;
+	} );
+
+	it( 'destroys children views', function() {
+		var view = new TestViewA();
+		var spy = bender.sinon.spy( view, 'destroy' );
+
+		// Append the view to the region.
+		region.views.add( view );
+		expect( region.views ).to.have.length( 1 );
+
+		region.destroy();
+
+		expect( region.views ).to.have.length( 0 );
+		expect( spy.calledOnce ).to.be.true;
+	} );
+} );
+
+function createRegionInstance() {
+	var Region = modules[ 'ui/region' ];
+	var View = modules[ 'ui/view' ];
+
+	class A extends View {
+		constructor() {
+			super();
+			this.template = { tag: 'a' };
+		}
+	}
+
+	class B extends View {
+		constructor() {
+			super();
+			this.template = { tag: 'b' };
+		}
+	}
+
+	TestViewA = A;
+	TestViewB = B;
+
+	el = document.createElement( 'div' );
+	region = new Region( 'foo', el );
+}

+ 198 - 0
packages/ckeditor5-engine/tests/ui/template.js

@@ -0,0 +1,198 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: core, ui */
+/* global HTMLElement */
+
+'use strict';
+
+var modules = bender.amd.require( 'ckeditor', 'ui/view', 'ui/template' );
+var Template;
+
+bender.tools.createSinonSandbox();
+beforeEach( createClassReferences );
+
+describe( 'constructor', function() {
+	it( 'accepts the definition', function() {
+		var def = {
+			tag: 'p'
+		};
+
+		expect( new Template( def ).def ).to.equal( def );
+	} );
+} );
+
+describe( 'render', function() {
+	it( 'returns null when no definition', function() {
+		expect( new Template().render() ).to.be.null;
+	} );
+
+	it( 'creates an element', function() {
+		var el = new Template( {
+			tag: 'p',
+			attrs: {
+				'class': [ 'a', 'b' ],
+				x: 'bar'
+			},
+			text: 'foo'
+		} ).render();
+
+		expect( el ).to.be.instanceof( HTMLElement );
+		expect( el.parentNode ).to.be.null();
+
+		expect( el.outerHTML ).to.be.equal( '<p class="a b" x="bar">foo</p>' );
+	} );
+
+	it( 'creates element\'s children', function() {
+		var el = new Template( {
+			tag: 'p',
+			attrs: {
+				a: 'A'
+			},
+			children: [
+				{
+					tag: 'b',
+					text: 'B'
+				},
+				{
+					tag: 'i',
+					text: 'C',
+					children: [
+						{
+							tag: 'b',
+							text: 'D'
+						}
+					]
+				}
+			]
+		} ).render();
+
+		expect( el.outerHTML ).to.be.equal( '<p a="A"><b>B</b><i>C<b>D</b></i></p>' );
+	} );
+} );
+
+describe( 'callback value', function() {
+	it( 'works for attributes', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+
+		var el = new Template( {
+			tag: 'p',
+			attrs: {
+				'class': spy1
+			},
+			children: [
+				{
+					tag: 'span',
+					attrs: {
+						id: spy2
+					}
+				}
+			]
+		} ).render();
+
+		sinon.assert.calledWithExactly( spy1, el, sinon.match.func );
+		sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.func );
+
+		spy1.firstCall.args[ 1 ]( el, 'foo' );
+		spy2.firstCall.args[ 1 ]( el.firstChild, 'bar' );
+
+		expect( el.outerHTML ).to.be.equal( '<p class="foo"><span id="bar"></span></p>' );
+	} );
+
+	it( 'works for "text" property', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+
+		var el = new Template( {
+			tag: 'p',
+			text: spy1,
+			children: [
+				{
+					tag: 'span',
+					text: spy2
+				}
+			]
+		} ).render();
+
+		sinon.assert.calledWithExactly( spy1, el, sinon.match.func );
+		sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.func );
+
+		spy2.firstCall.args[ 1 ]( el.firstChild, 'bar' );
+		expect( el.outerHTML ).to.be.equal( '<p><span>bar</span></p>' );
+
+		spy1.firstCall.args[ 1 ]( el, 'foo' );
+		expect( el.outerHTML ).to.be.equal( '<p>foo</p>' );
+	} );
+
+	it( 'works for "on" property', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+		var spy3 = bender.sinon.spy();
+		var spy4 = bender.sinon.spy();
+
+		var el = new Template( {
+			tag: 'p',
+			children: [
+				{
+					tag: 'span',
+					on: {
+						bar: spy2
+					}
+				}
+			],
+			on: {
+				foo: spy1,
+				baz: [ spy3, spy4 ]
+			}
+		} ).render();
+
+		sinon.assert.calledWithExactly( spy1, el, 'foo', null );
+		sinon.assert.calledWithExactly( spy2, el.firstChild, 'bar', null );
+		sinon.assert.calledWithExactly( spy3, el, 'baz', null );
+		sinon.assert.calledWithExactly( spy4, el, 'baz', null );
+	} );
+
+	it( 'works for "on" property with selectors', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+		var spy3 = bender.sinon.spy();
+		var spy4 = bender.sinon.spy();
+
+		var el = new Template( {
+			tag: 'p',
+			children: [
+				{
+					tag: 'span',
+					attrs: {
+						'id': 'x'
+					}
+				},
+				{
+					tag: 'span',
+					attrs: {
+						'class': 'y'
+					},
+					on: {
+						'bar@p': spy2
+					}
+				},
+			],
+			on: {
+				'foo@span': spy1,
+				'baz@.y': [ spy3, spy4 ]
+			}
+		} ).render();
+
+		sinon.assert.calledWithExactly( spy1, el, 'foo', 'span' );
+		sinon.assert.calledWithExactly( spy2, el.lastChild, 'bar', 'p' );
+		sinon.assert.calledWithExactly( spy3, el, 'baz', '.y' );
+		sinon.assert.calledWithExactly( spy4, el, 'baz', '.y' );
+	} );
+} );
+
+function createClassReferences() {
+	Template = modules[ 'ui/template' ];
+}

+ 495 - 0
packages/ckeditor5-engine/tests/ui/view.js

@@ -0,0 +1,495 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document, HTMLElement */
+/* bender-tags: core, ui */
+
+'use strict';
+
+var modules = bender.amd.require( 'ckeditor', 'ui/view', 'ui/region', 'ckeditorerror', 'model', 'eventinfo' );
+var View, TestView;
+var view;
+
+bender.tools.createSinonSandbox();
+
+beforeEach( updateModuleReference );
+
+describe( 'constructor', function() {
+	beforeEach( function() {
+		setTestViewClass();
+		setTestViewInstance();
+	} );
+
+	it( 'accepts the model', function() {
+		setTestViewInstance( { a: 'foo', b: 42 } );
+
+		expect( view.model ).to.be.an.instanceof( modules.model );
+		expect( view ).to.have.deep.property( 'model.a', 'foo' );
+		expect( view ).to.have.deep.property( 'model.b', 42 );
+	} );
+} );
+
+describe( 'instance', function() {
+	beforeEach( function() {
+		setTestViewClass();
+		setTestViewInstance();
+	} );
+
+	it( 'has no default element', function() {
+		expect( () => view.el ).to.throw( modules.ckeditorerror );
+	} );
+
+	it( 'has no default template', function() {
+		expect( view.template ).to.be.undefined();
+	} );
+
+	it( 'has no default regions', function() {
+		expect( view.regions ).to.have.length( 0 );
+	} );
+} );
+
+describe( 'bind', function() {
+	beforeEach( createViewInstanceWithTemplate );
+
+	it( 'returns a function that passes arguments', function() {
+		setTestViewInstance( { a: 'foo' } );
+
+		var spy = bender.sinon.spy();
+		var callback = view.bind( 'a', spy );
+
+		expect( spy.called ).to.be.false;
+
+		callback( 'el', 'updater' );
+		sinon.assert.calledOnce( spy );
+		sinon.assert.calledWithExactly( spy, 'el', 'foo' );
+
+		view.model.a = 'bar';
+		sinon.assert.calledTwice( spy );
+		expect( spy.secondCall.calledWithExactly( 'el', 'bar' ) ).to.be.true;
+	} );
+
+	it( 'allows binding attribute to the model', function() {
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				attrs: {
+					'class': this.bind( 'foo' )
+				},
+				text: 'abc'
+			};
+		} );
+
+		setTestViewInstance( { foo: 'bar' } );
+
+		expect( view.el.outerHTML ).to.be.equal( '<p class="bar">abc</p>' );
+
+		view.model.foo = 'baz';
+		expect( view.el.outerHTML ).to.be.equal( '<p class="baz">abc</p>' );
+	} );
+
+	it( 'allows binding "text" to the model', function() {
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				children: [
+					{
+						tag: 'b',
+						text: 'baz'
+					}
+				],
+				text: this.bind( 'foo' )
+			};
+		} );
+
+		setTestViewInstance( { foo: 'bar' } );
+
+		expect( view.el.outerHTML ).to.be.equal( '<p>bar<b>baz</b></p>' );
+
+		// TODO: A solution to avoid nuking the children?
+		view.model.foo = 'qux';
+		expect( view.el.outerHTML ).to.be.equal( '<p>qux</p>' );
+	} );
+
+	it( 'allows binding to the model with value processing', function() {
+		var callback = ( el, value ) =>
+			( value > 0 ? 'positive' : 'negative' );
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				attrs: {
+					'class': this.bind( 'foo', callback )
+				},
+				text: this.bind( 'foo', callback )
+			};
+		} );
+
+		setTestViewInstance( { foo: 3 } );
+		expect( view.el.outerHTML ).to.be.equal( '<p class="positive">positive</p>' );
+
+		view.model.foo = -7;
+		expect( view.el.outerHTML ).to.be.equal( '<p class="negative">negative</p>' );
+	} );
+
+	it( 'allows binding to the model with custom callback', function() {
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				attrs: {
+					'class': this.bind( 'foo', ( el, value ) => {
+						el.innerHTML = value;
+
+						if ( value == 'changed' ) {
+							return value;
+						}
+					} )
+				},
+				text: 'bar'
+			};
+		} );
+
+		setTestViewInstance( { foo: 'moo' } );
+		expect( view.el.outerHTML ).to.be.equal( '<p>moo</p>' );
+
+		view.model.foo = 'changed';
+		expect( view.el.outerHTML ).to.be.equal( '<p class="changed">changed</p>' );
+	} );
+} );
+
+describe( 'on', function() {
+	it( 'accepts plain binding', function() {
+		var spy = bender.sinon.spy();
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				on: {
+					x: 'a',
+				}
+			};
+		} );
+
+		setTestViewInstance();
+
+		view.on( 'a', spy );
+
+		dispatchEvent( view.el, 'x' );
+		sinon.assert.calledWithExactly( spy,
+			sinon.match.has( 'name', 'a' ),
+			sinon.match.has( 'target', view.el )
+		);
+	} );
+
+	it( 'accepts an array of event bindings', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				on: {
+					x: [ 'a', 'b' ]
+				}
+			};
+		} );
+
+		setTestViewInstance();
+
+		view.on( 'a', spy1 );
+		view.on( 'b', spy2 );
+
+		dispatchEvent( view.el, 'x' );
+		sinon.assert.calledWithExactly( spy1,
+			sinon.match.has( 'name', 'a' ),
+			sinon.match.has( 'target', view.el )
+		);
+		sinon.assert.calledWithExactly( spy2,
+			sinon.match.has( 'name', 'b' ),
+			sinon.match.has( 'target', view.el )
+		);
+	} );
+
+	it( 'accepts DOM selectors', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+		var spy3 = bender.sinon.spy();
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				children: [
+					{
+						tag: 'span',
+						attrs: {
+							'class': 'y',
+						},
+						on: {
+							'test@p': 'c'
+						}
+					},
+					{
+						tag: 'div',
+						children: [
+							{
+								tag: 'span',
+								attrs: {
+									'class': 'y',
+								}
+							}
+						],
+					}
+				],
+				on: {
+					'test@.y': 'a',
+					'test@div': 'b'
+				}
+			};
+		} );
+
+		setTestViewInstance();
+
+		view.on( 'a', spy1 );
+		view.on( 'b', spy2 );
+		view.on( 'c', spy3 );
+
+		// Test "test@p".
+		dispatchEvent( view.el, 'test' );
+
+		sinon.assert.callCount( spy1, 0 );
+		sinon.assert.callCount( spy2, 0 );
+		sinon.assert.callCount( spy3, 0 );
+
+		// Test "test@.y".
+		dispatchEvent( view.el.firstChild, 'test' );
+
+		expect( spy1.firstCall.calledWithExactly(
+			sinon.match.has( 'name', 'a' ),
+			sinon.match.has( 'target', view.el.firstChild )
+		) ).to.be.true;
+
+		sinon.assert.callCount( spy2, 0 );
+		sinon.assert.callCount( spy3, 0 );
+
+		// Test "test@div".
+		dispatchEvent( view.el.lastChild, 'test' );
+
+		sinon.assert.callCount( spy1, 1 );
+
+		expect( spy2.firstCall.calledWithExactly(
+			sinon.match.has( 'name', 'b' ),
+			sinon.match.has( 'target', view.el.lastChild )
+		) ).to.be.true;
+
+		sinon.assert.callCount( spy3, 0 );
+
+		// Test "test@.y".
+		dispatchEvent( view.el.lastChild.firstChild, 'test' );
+
+		expect( spy1.secondCall.calledWithExactly(
+			sinon.match.has( 'name', 'a' ),
+			sinon.match.has( 'target', view.el.lastChild.firstChild )
+		) ).to.be.true;
+
+		sinon.assert.callCount( spy2, 1 );
+		sinon.assert.callCount( spy3, 0 );
+	} );
+
+	it( 'accepts function callbacks', function() {
+		var spy1 = bender.sinon.spy();
+		var spy2 = bender.sinon.spy();
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				children: [
+					{
+						tag: 'span'
+					}
+				],
+				on: {
+					x: spy1,
+					'y@span': [ spy2, 'c' ],
+				}
+			};
+		} );
+
+		setTestViewInstance();
+
+		dispatchEvent( view.el, 'x' );
+		dispatchEvent( view.el.firstChild, 'y' );
+
+		sinon.assert.calledWithExactly( spy1,
+			sinon.match.has( 'target', view.el )
+		);
+
+		sinon.assert.calledWithExactly( spy2,
+			sinon.match.has( 'target', view.el.firstChild )
+		);
+	} );
+
+	it( 'supports event delegation', function() {
+		var spy = bender.sinon.spy();
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				children: [
+					{
+						tag: 'span'
+					}
+				],
+				on: {
+					x: 'a',
+				}
+			};
+		} );
+
+		setTestViewInstance();
+
+		view.on( 'a', spy );
+
+		dispatchEvent( view.el.firstChild, 'x' );
+		sinon.assert.calledWithExactly( spy,
+			sinon.match.has( 'name', 'a' ),
+			sinon.match.has( 'target', view.el.firstChild )
+		);
+	} );
+
+	it( 'works for future elements', function() {
+		var spy = bender.sinon.spy();
+
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				on: {
+					'test@div': 'a'
+				}
+			};
+		} );
+
+		setTestViewInstance();
+
+		view.on( 'a', spy );
+
+		var div = document.createElement( 'div' );
+		view.el.appendChild( div );
+
+		dispatchEvent( div, 'test' );
+		sinon.assert.calledWithExactly( spy, sinon.match.has( 'name', 'a' ), sinon.match.has( 'target', div ) );
+	} );
+} );
+
+describe( 'render', function() {
+	beforeEach( createViewInstanceWithTemplate );
+
+	it( 'creates an element from template', function() {
+		setTestViewInstance( { a: 1 } );
+
+		expect( view.el ).to.be.an.instanceof( HTMLElement );
+		expect( view.el.nodeName ).to.be.equal( 'A' );
+	} );
+} );
+
+describe( 'destroy', function() {
+	beforeEach( createViewInstanceWithTemplate );
+
+	it( 'detaches the model', function() {
+		expect( view.model ).to.be.an.instanceof( modules.model );
+
+		view.destroy();
+
+		expect( view.model ).to.be.null;
+	} );
+
+	it( 'detaches the element', function() {
+		// Append the views's element to some container.
+		var container = document.createElement( 'div' );
+		container.appendChild( view.el );
+
+		expect( view.el.nodeName ).to.be.equal( 'A' );
+		expect( view.el ).to.be.an.instanceof( HTMLElement );
+		expect( view.el.parentNode ).to.be.equal( container );
+
+		view.destroy();
+
+		expect( view.el ).to.be.an.instanceof( HTMLElement );
+		expect( view.el.parentNode ).to.be.null;
+	} );
+
+	it( 'destroys child regions', function() {
+		var Region = modules[ 'ui/region' ];
+		var region = new Region( 'test' );
+		var spy = bender.sinon.spy( region, 'destroy' );
+
+		view.regions.add( region );
+		view.destroy();
+
+		expect( view.regions ).to.have.length( 0 );
+		expect( spy.calledOnce ).to.be.true;
+	} );
+
+	it( 'detaches bound model listeners', function() {
+		setTestViewClass( function() {
+			return {
+				tag: 'p',
+				text: this.bind( 'foo' )
+			};
+		} );
+
+		setTestViewInstance( { foo: 'bar' } );
+
+		// Keep the reference after the view is destroyed.
+		var model = view.model;
+
+		expect( view.el.outerHTML ).to.be.equal( '<p>bar</p>' );
+
+		model.foo = 'baz';
+		expect( view.el.outerHTML ).to.be.equal( '<p>baz</p>' );
+
+		view.destroy();
+
+		model.foo = 'abc';
+		expect( view.el.outerHTML ).to.be.equal( '<p>baz</p>' );
+	} );
+} );
+
+function updateModuleReference() {
+	View = modules[ 'ui/view' ];
+}
+
+function createViewInstanceWithTemplate() {
+	setTestViewClass( () => ( { tag: 'a' } ) );
+	setTestViewInstance();
+}
+
+function setTestViewClass( template ) {
+	TestView = class V extends View {
+		constructor( model ) {
+			super( model );
+
+			if ( template ) {
+				this.template = template.call( this );
+			}
+		}
+	};
+}
+
+function setTestViewInstance( model ) {
+	view = new TestView( model );
+
+	if ( view.template ) {
+		document.body.appendChild( view.el );
+	}
+}
+
+function dispatchEvent( el, domEvtName ) {
+	if ( !el.parentNode ) {
+		throw( 'To dispatch an event, element must be in DOM. Otherwise #target is null.' );
+	}
+
+	el.dispatchEvent( new Event( domEvtName, {
+		bubbles: true
+	} ) );
+}