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

Merge pull request #39 from ckeditor/t/3

T/3: An utility to extend existing templates.
Piotrek Koszuliński 9 лет назад
Родитель
Сommit
9aed142e8f

+ 5 - 4
packages/ckeditor5-ui/src/editableui/editableuiview.js

@@ -6,6 +6,7 @@
 'use strict';
 
 import View from '../view.js';
+import Template from '../template.js';
 
 /**
  * @memberOf ui.editableUI
@@ -23,13 +24,13 @@ export default class EditableUIView extends View {
 	constructor( model, locale, editableElement ) {
 		super( model, locale );
 
-		const bind = this.attributeBinder;
+		const bind = this.bind;
 
 		if ( editableElement ) {
 			this.element = this.editableElement = editableElement;
 		}
 
-		this.template = {
+		this.template = new Template( {
 			tag: 'div',
 			attributes: {
 				class: [
@@ -38,7 +39,7 @@ export default class EditableUIView extends View {
 				],
 				contenteditable: bind.to( 'isReadOnly', value => !value ),
 			}
-		};
+		} );
 
 		/**
 		 * The element which is the main editable element (usually the one with `contentEditable="true"`).
@@ -54,7 +55,7 @@ export default class EditableUIView extends View {
 	 */
 	init() {
 		if ( this.editableElement ) {
-			this.applyTemplateToElement( this.editableElement, this.template );
+			this.template.apply( this.editableElement );
 		} else {
 			this.editableElement = this.element;
 		}

+ 8 - 6
packages/ckeditor5-ui/src/editableui/inline/inlineeditableuiview.js

@@ -6,6 +6,7 @@
 'use strict';
 
 import EditableUIView from '../../editableui/editableuiview.js';
+import Template from '../../template.js';
 
 /**
  * The class implementing an inline {@link ui.editableUI.EditableUIView}.
@@ -27,12 +28,13 @@ export default class InlineEditableUIView extends EditableUIView {
 
 		const label = this.t( 'Rich Text Editor, %0', [ this.model.name ] );
 
-		Object.assign( this.template.attributes, {
-			role: 'textbox',
-			'aria-label': label,
-			title: label
+		Template.extend( this.template, {
+			attributes: {
+				role: 'textbox',
+				'aria-label': label,
+				title: label,
+				class: 'ck-editor__editable_inline'
+			}
 		} );
-
-		this.template.attributes.class.push( 'ck-editor__editable_inline' );
 	}
 }

+ 3 - 2
packages/ckeditor5-ui/src/editorui/boxed/boxededitoruiview.js

@@ -7,6 +7,7 @@
 
 import EditorUIView from '../../editorui/editoruiview.js';
 import uid from '../../../utils/uid.js';
+import Template from '../../template.js';
 
 /**
  * Boxed editor UI view.
@@ -27,7 +28,7 @@ export default class BoxedEditorUIView extends EditorUIView {
 		const t = this.t;
 		const ariaLabelUid = uid();
 
-		this.template = {
+		this.template = new Template( {
 			tag: 'div',
 
 			attributes: {
@@ -69,7 +70,7 @@ export default class BoxedEditorUIView extends EditorUIView {
 					}
 				}
 			]
-		};
+		} );
 
 		this.register( 'top', '.ck-editor__top' );
 		this.register( 'main', '.ck-editor__main' );

+ 3 - 2
packages/ckeditor5-ui/src/editorui/editoruiview.js

@@ -6,6 +6,7 @@
 'use strict';
 
 import View from '../view.js';
+import Template from '../template.js';
 
 /**
  * Base class for the editor main views.
@@ -47,11 +48,11 @@ export default class EditorUIView extends View {
 		const bodyElement = document.createElement( 'div' );
 		document.body.appendChild( bodyElement );
 
-		this.applyTemplateToElement( bodyElement, {
+		new Template( {
 			attributes: {
 				class: 'ck-body ck-reset-all'
 			}
-		} );
+		} ).apply( bodyElement );
 
 		this._bodyRegionContainer = bodyElement;
 

+ 3 - 2
packages/ckeditor5-ui/src/iconmanagerview.js

@@ -6,6 +6,7 @@
 'use strict';
 
 import View from './view.js';
+import Template from './template.js';
 
 /**
  * Icon manager view using {@link ui.iconManager.IconManagerModel}.
@@ -18,13 +19,13 @@ export default class IconManagerView extends View {
 	constructor( model, locale ) {
 		super( model, locale );
 
-		this.template = {
+		this.template = new Template( {
 			tag: 'svg',
 			ns: 'http://www.w3.org/2000/svg',
 			attributes: {
 				class: 'ck-icon-manager-sprite'
 			}
-		};
+		} );
 	}
 
 	init() {

+ 733 - 101
packages/ckeditor5-ui/src/template.js

@@ -8,9 +8,33 @@
 'use strict';
 
 import CKEditorError from '../utils/ckeditorerror.js';
+import mix from '../utils/mix.js';
+import EmitterMixin from '/ckeditor5/utils/emittermixin.js';
+import cloneDeepWith from '../utils/lib/lodash/clonedeepwith.js';
+
+const bindToSymbol = Symbol( 'bindTo' );
+const bindIfSymbol = Symbol( 'bindIf' );
 
 /**
- * Basic Template class.
+ * A basic Template class. It renders DOM HTMLElements from {@link ui.TemplateDefinition} and supports
+ * element attributes, children, bindings to {@link utils.ObservableMixin} instances and DOM events
+ * propagation. For example:
+ *
+ *		new Template( {
+ *			tag: 'p',
+ *			attributes: {
+ *				class: 'foo'
+ *			},
+ *			children: [
+ *				'A paragraph.'
+ *			]
+ *		} ).render();
+ *
+ * will render the following HTMLElement:
+ *
+ *		<p class="foo">A paragraph.</p>
+ *
+ * See {@link ui.TemplateDefinition} to know more about templates and see complex examples.
  *
  * @memberOf ui
  */
@@ -21,13 +45,17 @@ export default class Template {
 	 * @param {ui.TemplateDefinition} def The definition of the template.
 	 */
 	constructor( def ) {
+		const defClone = clone( def );
+
+		normalize( defClone );
+
 		/**
 		 * Definition of this template.
 		 *
 		 * @readonly
 		 * @member {ui.TemplateDefinition} ui.Template#definition
 		 */
-		this.definition = def;
+		this.definition = defClone;
 	}
 
 	/**
@@ -38,17 +66,34 @@ export default class Template {
 	 * @returns {HTMLElement}
 	 */
 	render() {
-		return this._renderNode( this.definition, null, true );
+		return this._renderNode( this.definition, undefined, true );
 	}
 
 	/**
 	 * Applies template {@link ui.Template#def} to existing DOM tree.
 	 *
 	 * **Note:** No new DOM nodes (elements, text nodes) will be created.
+	 *		const element = document.createElement( 'div' );
+	 *		const bind = Template.bind( observableInstance, emitterInstance );
 	 *
-	 * @see ui.Template#render
-	 * @see ui.View#applyTemplateToElement.
+	 *		const template = new Template( {
+	 *			attrs: {
+	 *				id: 'first-div',
+	 *				class: bind.to( 'divClass' )
+	 *			},
+	 *			on: {
+	 *				click: bind( 'elementClicked' ) // Will be fired by the observableInstance.
+	 *			}
+	 *			children: [
+	 *				'Div text.'
+	 *			]
+	 *		} );
+	 *
+	 *		template.apply( element );
 	 *
+	 *		element.outerHTML == "<div id="first-div" class="my-div">Div text.</div>"
+	 *
+	 * @see ui.Template#render
 	 * @param {Node} element Root element for template to apply.
 	 */
 	apply( node ) {
@@ -58,12 +103,195 @@ export default class Template {
 			 *
 			 * @error ui-template-wrong-syntax
 			 */
-			throw new CKEditorError( 'ui-template-wrong-node' );
+			throw new CKEditorError( 'ui-template-wrong-node: No DOM Node specified.' );
 		}
 
 		return this._renderNode( this.definition, node );
 	}
 
+	/**
+	 * An entry point to the interface which allows binding DOM nodes to {@link utils.ObservableMixin}.
+	 * There are two types of bindings:
+	 *
+	 * * `HTMLElement` attributes or Text Node `textContent` can be synchronized with {@link utils.ObservableMixin}
+	 * instance attributes. See {@link ui.Template.bind#to} and {@link ui.Template.bind#if}.
+	 *
+	 * * DOM events fired on `HTMLElement` can be propagated through {@link utils.ObservableMixin}.
+	 * See {@link ui.Template.bind#to}.
+	 *
+	 * @param {utils.ObservableMixin} observable An instance of ObservableMixin class.
+	 * @param {utils.EmitterMixin} emitter An instance of `EmitterMixin` class. It listens
+	 * to `observable` attribute changes and DOM Events, depending on the binding. Usually {@link ui.View} instance.
+	 * @returns {ui.TemplateBinding}
+	 */
+	static bind( observable, emitter ) {
+		return {
+			/**
+			 * Binds {@link utils.ObservableMixin} instance to:
+			 *  * HTMLElement attribute or Text Node `textContent` so remains in sync with the Observable when it changes:
+			 *  * HTMLElement DOM event, so the DOM events are propagated through Observable.
+			 *
+			 *		const bind = Template.bind( observableInstance, emitterInstance );
+			 *
+			 *		new Template( {
+			 *			tag: 'p',
+			 *			attributes: {
+			 *				// class="..." attribute gets bound to `observableInstance#a`
+			 *				'class': bind.to( 'a' )
+			 *			},
+			 *			children: [
+			 *				// <p>...</p> gets bound to `observableInstance#b`; always `toUpperCase()`.
+			 *				{ text: bind.to( 'b', ( value, node ) => value.toUpperCase() ) }
+			 *			],
+			 *			on: {
+			 *				click: [
+			 *					// "clicked" event will be fired on `observableInstance` when "click" fires in DOM.
+			 *					bind( 'clicked' ),
+			 *
+			 *					// A custom callback function will be executed when "click" fires in DOM.
+			 *					bind( () => {
+			 *						...
+			 *					} )
+			 *				]
+			 *			}
+			 *		} ).render();
+			 *
+			 *		const bind = Template.bind( observableInstance, emitterInstance );
+			 *
+			 *		new Template( {
+			 *			tag: 'p',
+			 *		} ).render();
+			 *
+			 * @static
+			 * @method ui.Template.bind#to
+			 * @param {String} attribute Name of {@link utils.ObservableMixin} used in the binding.
+			 * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
+			 * @return {ui.TemplateBinding}
+			 */
+			to( eventNameOrFuncionOrAttribute, callback ) {
+				return {
+					type: bindToSymbol,
+					eventNameOrFunction: eventNameOrFuncionOrAttribute,
+					attribute: eventNameOrFuncionOrAttribute,
+					observable, emitter, callback
+				};
+			},
+
+			/**
+			 * Binds {@link utils.ObservableMixin} to HTMLElement attribute or Text Node `textContent`
+			 * so remains in sync with the Model when it changes. Unlike {@link ui.Template.bind#to},
+			 * it controls the presence of the attribute/`textContent` depending on the "falseness" of
+			 * {@link utils.ObservableMixin} attribute.
+			 *
+			 *		const bind = Template.bind( observableInstance, emitterInstance );
+			 *
+			 *		new Template( {
+			 *			tag: 'input',
+			 *			attributes: {
+			 *				// <input checked> when `observableInstance#a` is not undefined/null/false/''
+			 *				// <input> when `observableInstance#a` is undefined/null/false
+			 *				checked: bind.if( 'a' )
+			 *			},
+			 *			children: [
+			 *				{
+			 *					// <input>"b-is-not-set"</input> when `observableInstance#b` is undefined/null/false/''
+			 *					// <input></input> when `observableInstance#b` is not "falsy"
+			 *					text: bind.if( 'b', 'b-is-not-set', ( value, node ) => !value )
+			 *				}
+			 *			]
+			 *		} ).render();
+			 *
+			 * @static
+			 * @method ui.Template.bind#if
+			 * @param {String} attribute An attribute name of {@link utils.ObservableMixin} used in the binding.
+			 * @param {String} [valueIfTrue] Value set when {@link utils.ObservableMixin} attribute is not undefined/null/false/''.
+			 * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
+			 * @return {ui.TemplateBinding}
+			 */
+			if( attribute, valueIfTrue, callback ) {
+				return {
+					type: bindIfSymbol,
+					observable, emitter, attribute, valueIfTrue, callback
+				};
+			}
+		};
+	}
+
+	/**
+	 * Extends {@link ui.Template} or {@link ui.TemplateDefinition} with additional content.
+	 *
+	 *		const bind = Template.bind( observable, emitterInstance );
+	 *		const instance = new Template( {
+	 *			tag: 'p',
+	 *			attributes: {
+	 *				class: 'a',
+	 *				data-x: bind.to( 'foo' )
+	 *			},
+	 *			children: [
+	 *				{
+	 *					tag: 'span',
+	 *					attributes: {
+	 *						class: 'b'
+	 *					},
+	 *					children: [
+	 *						'Span'
+	 *					]
+	 *				}
+	 *			]
+	 *		 } );
+	 *
+	 *		// Instance-level extension.
+	 *		Template.extend( instance, {
+	 *			attributes: {
+	 *				class: 'b',
+	 *				data-x: bind.to( 'bar' )
+	 *			},
+	 *			children: [
+	 *				{
+	 *					attributes: {
+	 *						class: 'c'
+	 *					}
+	 *				}
+	 *			]
+	 *		} );
+	 *
+	 *		// Fragment extension.
+	 *		Template.extend( instance.definition.children[ 0 ], {
+	 *			attributes: {
+	 *				class: 'd'
+	 *			}
+	 *		} );
+	 *
+	 * the `instance.render().outerHTML` is
+	 *
+	 *		<p class="a b" data-x="{ observable.foo } { observable.bar }">
+	 *			<span class="b c d">Span</span>
+	 *		</p>
+	 *
+	 * @param {ui.Template|ui.TemplateDefinition} instanceOrDef Existing Template instance or definition to be extended.
+	 * @param {ui.TemplateDefinition} extDef An extension to existing instance or definition.
+	 */
+	static extend( instanceOrDef, extDef ) {
+		const extDefClone = clone( extDef );
+
+		normalize( extDefClone );
+
+		if ( instanceOrDef instanceof Template ) {
+			extendTemplateDefinition( instanceOrDef.definition, extDefClone );
+		}
+		// Extend a particular child in existing template instance.
+		//
+		//		Template.extend( instance.definition.children[ 0 ], {
+		//			attributes: {
+		//				class: 'd'
+		//			}
+		//		} );
+		//
+		else {
+			extendTemplateDefinition( instanceOrDef, extDefClone );
+		}
+	}
+
 	/**
 	 * Renders a DOM Node from definition.
 	 *
@@ -74,15 +302,14 @@ export default class Template {
 	 * @returns {HTMLElement} A rendered Node.
 	 */
 	_renderNode( def, applyNode, intoFragment ) {
-		const isText = def.text || typeof def == 'string';
 		let isInvalid;
 
 		if ( applyNode ) {
 			// When applying, a definition cannot have "tag" and "text" at the same time.
-			isInvalid = def.tag && isText;
+			isInvalid = def.tag && def.text;
 		} else {
-			// When rendering, a definition must have either "tag" or "text": XOR( def.tag, isText ).
-			isInvalid = def.tag ? isText : !isText;
+			// When rendering, a definition must have either "tag" or "text": XOR( def.tag, def.text ).
+			isInvalid = def.tag ? def.text : !def.text;
 		}
 
 		if ( isInvalid ) {
@@ -92,10 +319,10 @@ export default class Template {
 			 *
 			 * @error ui-template-wrong-syntax
 			 */
-			throw new CKEditorError( 'ui-template-wrong-syntax' );
+			throw new CKEditorError( 'ui-template-wrong-syntax: Node definition must have either "tag" or "text" when rendering new Node.' );
 		}
 
-		return isText ?
+		return def.text ?
 			this._renderText( def, applyNode ) : this._renderElement( def, applyNode, intoFragment );
 	}
 
@@ -109,7 +336,7 @@ export default class Template {
 	 * @returns {HTMLElement} A rendered element.
 	 */
 	_renderElement( def, applyElement, intoFragment ) {
-		let el = applyElement ||
+		const el = applyElement ||
 			document.createElementNS( def.ns || 'http://www.w3.org/1999/xhtml', def.tag );
 
 		this._renderElementAttributes( def, el );
@@ -125,8 +352,8 @@ export default class Template {
 			this._renderElementChildren( def, el, !!applyElement );
 		}
 
-		// Activate DOM bindings for event listeners.
-		this._activateElementListenerAttachers( def, el );
+		// Setup DOM bindings event listeners.
+		this._setUpListeners( def, el );
 
 		return el;
 	}
@@ -136,28 +363,22 @@ export default class Template {
 	 *
 	 * @protected
 	 * @param {TemplateDefinition|String} def Definition of Text or its value.
-	 * @param {HTMLElement} applyText If specified, template `def` will be applied to existing Text Node.
+	 * @param {HTMLElement} textNode If specified, template `def` will be applied to existing Text Node.
 	 * @returns {Text} A rendered Text.
 	 */
-	_renderText( defOrText, applyText ) {
-		const textNode = applyText || document.createTextNode( '' );
-
-		// Check if there's a binder available for this Text Node.
-		const binder = defOrText._modelBinders && defOrText._modelBinders.text;
-
-		// Activate binder if one. Cases:
-		//		{ text: bind.to( ... ) }
-		//		{ text: [ 'foo', bind.to( ... ), ... ] }
-		if ( binder ) {
-			binder( textNode, getTextNodeUpdater( textNode ) );
+	_renderText( valueSchemaOrText, textNode = document.createTextNode( '' ) ) {
+		// Check if this Text Node is bound to Observable. Cases:
+		//		{ text: [ Template.bind.to( ... ) ] }
+		//		{ text: [ 'foo', Template.bind.to( ... ), ... ] }
+		if ( hasBinding( valueSchemaOrText.text ) ) {
+			this._bindToObservable( valueSchemaOrText.text, textNode, getTextUpdater( textNode ) );
 		}
 
 		// Simply set text. Cases:
 		// 		{ text: [ 'all', 'are', 'static' ] }
-		// 		{ text: 'foo' }
-		// 		'foo'
+		// 		{ text: [ 'foo' ] }
 		else {
-			textNode.textContent = defOrText.text || defOrText;
+			textNode.textContent = valueSchemaOrText.text.join( '' );
 		}
 
 		return textNode;
@@ -170,42 +391,43 @@ export default class Template {
 	 * @param {ui.TemplateDefinition} def Definition of an element.
 	 * @param {HTMLElement} el Element which is rendered.
 	 */
-	_renderElementAttributes( def, el ) {
-		const attributes = def.attributes;
-		const binders = def._modelBinders && def._modelBinders.attributes;
-		let binder, attrName, attrValue, attrNs;
+	_renderElementAttributes( { attributes }, el ) {
+		let attrName, attrValue, attrNs;
 
 		if ( !attributes ) {
 			return;
 		}
 
 		for ( attrName in attributes ) {
-			// Check if there's a binder available for this attribute.
-			binder = binders && binders[ attrName ];
 			attrValue = attributes[ attrName ];
-			attrNs = attrValue.ns || null;
-
-			// Activate binder if one. Cases:
-			// 		{ class: [ 'bar', bind.to( ... ), 'baz' ] }
-			// 		{ class: bind.to( ... ) }
-			// 		{ class: { ns: 'abc', value: bind.to( ... ) } }
-			if ( binder ) {
-				binder( el, getElementAttributeUpdater( el, attrName, attrNs ) );
+			attrNs = attrValue[ 0 ].ns || null;
+
+			// Activate binding if one is found. Cases:
+			// 		{ class: [ Template.bind.to( ... ) ] }
+			// 		{ class: [ 'bar', Template.bind.to( ... ), 'baz' ] }
+			// 		{ class: { ns: 'abc', value: Template.bind.to( ... ) } }
+			if ( hasBinding( attrValue ) ) {
+				// Normalize attributes with additional data like namespace:
+				//		{ class: { ns: 'abc', value: [ ... ] } }
+				this._bindToObservable(
+					attrValue[ 0 ].value || attrValue,
+					el,
+					getAttributeUpdater( el, attrName, attrNs )
+				);
 			}
 
 			// Otherwise simply set the attribute.
+			// 		{ class: [ 'foo' ] }
 			// 		{ class: [ 'all', 'are', 'static' ] }
-			// 		{ class: 'foo' }
-			// 		{ class: { ns: 'abc', value: 'foo' } }
+			// 		{ class: [ { ns: 'abc', value: [ 'foo' ] } ] }
 			else {
-				attrValue = attrValue.value || attrValue;
-
-				// Attribute can be an array. Merge array elements:
-				if ( Array.isArray( attrValue ) ) {
-					attrValue = attrValue.reduce( function binderValueReducer( prev, cur ) {
-						return prev === '' ? `${cur}` : `${prev} ${cur}`;
-					} );
-				}
+				attrValue = attrValue
+					// Retrieve "values" from { class: [ { ns: 'abc', value: [ ... ] } ] }
+					.map( v => v ? ( v.value || v ) : v )
+					// Flatten the array.
+					.reduce( ( p, n ) => p.concat( n ), [] )
+					// Convert into string.
+					.reduce( arrayValueReducer );
 
 				el.setAttributeNS( attrNs, attrName, attrValue );
 			}
@@ -238,40 +460,154 @@ export default class Template {
 	 *
 	 * @protected
 	 * @param {ui.TemplateDefinition} def Definition of an element.
-	 * @param {HTMLElement} el Element which is rendered.
+	 * @param {HTMLElement} el Element which is being rendered.
 	 */
-	_activateElementListenerAttachers( def, el ) {
+	_setUpListeners( def, el ) {
 		if ( !def.on ) {
 			return;
 		}
 
-		const attachers = def.on._listenerAttachers;
-
-		Object.keys( attachers )
-			.map( name => [ name, ...name.split( '@' ) ] )
-			.forEach( split => {
-				// TODO: ES6 destructuring.
-				const key = split[ 0 ];
-				const evtName = split[ 1 ];
-				const evtSelector = split[ 2 ] || null;
+		for ( let key in def.on ) {
+			const [ domEvtName, domSelector ] = key.split( '@' );
+
+			for ( let schemaItem of def.on[ key ] ) {
+				schemaItem.emitter.listenTo( el, domEvtName, ( evt, domEvt ) => {
+					if ( !domSelector || domEvt.target.matches( domSelector ) ) {
+						if ( typeof schemaItem.eventNameOrFunction == 'function' ) {
+							schemaItem.eventNameOrFunction( domEvt );
+						} else {
+							schemaItem.observable.fire( schemaItem.eventNameOrFunction, domEvt );
+						}
+					}
+				} );
+			}
+		}
+	}
 
-				if ( Array.isArray( attachers[ key ] ) ) {
-					attachers[ key ].forEach( i => i( el, evtName, evtSelector ) );
-				} else {
-					attachers[ key ]( el, evtName, evtSelector );
-				}
+	/**
+	 * For given {@link ui.TemplateValueSchema} containing {@link ui.TemplateBinding} it activates the
+	 * binding and sets its initial value.
+	 *
+	 * Note: {@link ui.TemplateValueSchema} can be for HTMLElement attributes or Text Node `textContent`.
+	 *
+	 * @protected
+	 * @param {ui.TemplateValueSchema} valueSchema
+	 * @param {Node} node DOM Node to be updated when {@link utils.ObservableMixin} changes.
+	 * @param {Function} domUpdater A function which updates DOM (like attribute or text).
+	 */
+	_bindToObservable( valueSchema ) {
+		valueSchema
+			// Filter inactive bindings from schema, like static strings, etc.
+			.filter( item => item.observable )
+			// Let the emitter listen to observable change:attribute event.
+			// TODO: Reduce the number of listeners attached as many bindings may listen
+			// to the same observable attribute.
+			.forEach( ( { emitter, observable, attribute } ) => {
+				emitter.listenTo( observable, 'change:' + attribute, () => {
+					syncBinding( ...arguments );
+				} );
 			} );
+
+		// Set initial values.
+		syncBinding( ...arguments );
+	}
+}
+
+mix( Template, EmitterMixin );
+
+// Checks whether given {@link ui.TemplateValueSchema} contains a
+// {@link ui.TemplateBinding}.
+//
+// @param {ui.TemplateValueSchema} valueSchema
+// @returns {Boolean}
+function hasBinding( valueSchema ) {
+	if ( !valueSchema ) {
+		return false;
+	}
+
+	// Normalize attributes with additional data like namespace:
+	// 		class: { ns: 'abc', value: [ ... ] }
+	if ( valueSchema.value ) {
+		valueSchema = valueSchema.value;
+	}
+
+	if ( Array.isArray( valueSchema ) ) {
+		return valueSchema.some( hasBinding );
+	} else if ( valueSchema.observable ) {
+		return true;
+	}
+
+	return false;
+}
+
+// Assembles the value using {@link ui.TemplateValueSchema} and stores it in a form of
+// an Array. Each entry of an Array corresponds to one of {@link ui.TemplateValueSchema}
+// items.
+//
+// @param {ui.TemplateValueSchema} valueSchema
+// @param {Node} node DOM Node updated when {@link utils.ObservableMixin} changes.
+// @return {Array}
+function getBindingValue( valueSchema, domNode ) {
+	return valueSchema.map( schemaItem => {
+		let { observable, callback, type } = schemaItem;
+
+		if ( observable ) {
+			let modelValue = observable[ schemaItem.attribute ];
+
+			// Process the value with the callback.
+			if ( callback ) {
+				modelValue = callback( modelValue, domNode );
+			}
+
+			if ( type === bindIfSymbol ) {
+				return !!modelValue ? schemaItem.valueIfTrue || true : '';
+			} else {
+				return modelValue;
+			}
+		} else {
+			return schemaItem;
+		}
+	} );
+}
+
+// A function executed each time bound Observable attribute changes, which updates DOM with a value
+// constructed from {@link ui.TemplateValueSchema}.
+//
+// @param {ui.TemplateValueSchema} valueSchema
+// @param {Node} node DOM Node updated when {@link utils.ObservableMixin} changes.
+// @param {Function} domUpdater A function which updates DOM (like attribute or text).
+function syncBinding( valueSchema, domNode, domUpdater ) {
+	let value = getBindingValue( valueSchema, domNode );
+	let shouldSet;
+
+	// Check if valueSchema is a single Template.bind.if, like:
+	//		{ class: Template.bind.if( 'foo' ) }
+	if ( valueSchema.length == 1 && valueSchema[ 0 ].type == bindIfSymbol ) {
+		value = value[ 0 ];
+		shouldSet = value !== '';
+
+		if ( shouldSet ) {
+			value = value === true ? '' : value;
+		}
+	} else {
+		value = value.reduce( arrayValueReducer, '' );
+		shouldSet = value;
+	}
+
+	if ( shouldSet ) {
+		domUpdater.set( value );
+	} else {
+		domUpdater.remove();
 	}
 }
 
 // Returns an object consisting of `set` and `remove` functions, which
 // can be used in the context of DOM Node to set or reset `textContent`.
-// @see ui.View#_getModelBinder
+// @see ui.View#_bindToObservable
 //
-// @private
 // @param {Node} node DOM Node to be modified.
 // @returns {Object}
-function getTextNodeUpdater( node ) {
+function getTextUpdater( node ) {
 	return {
 		set( value ) {
 			node.textContent = value;
@@ -285,14 +621,13 @@ function getTextNodeUpdater( node ) {
 
 // Returns an object consisting of `set` and `remove` functions, which
 // can be used in the context of DOM Node to set or reset an attribute.
-// @see ui.View#_getModelBinder
+// @see ui.View#_bindToObservable
 //
-// @private
 // @param {Node} node DOM Node to be modified.
 // @param {String} attrName Name of the attribute to be modified.
-// @param {String} [ns] Namespace to use.
+// @param {String} [ns=null] Namespace to use.
 // @returns {Object}
-function getElementAttributeUpdater( el, attrName, ns = null ) {
+function getAttributeUpdater( el, attrName, ns = null ) {
 	return {
 		set( value ) {
 			el.setAttributeNS( ns, attrName, value );
@@ -304,11 +639,258 @@ function getElementAttributeUpdater( el, attrName, ns = null ) {
 	};
 }
 
+// Clones definition of the template.
+//
+// @param {ui.TemplateDefinition} def
+// @returns {ui.TemplateDefinition}
+function clone( def ) {
+	const clone = cloneDeepWith( def, value => {
+		// Don't clone Template.bind* bindings because there are references
+		// to Observable and DOMEmitterMixin instances inside, which are external
+		// to the Template.
+		if ( value && value.type ) {
+			return value;
+		}
+	} );
+
+	return clone;
+}
+
+// Normalizes given {@link ui.TemplateDefinition}.
+//
+// See:
+//  * {@link normalizeAttributes}
+//  * {@link normalizeListeners}
+//  * {@link normalizeTextString}
+//  * {@link normalizeTextDefinition}
+//
+// @param {ui.TemplateDefinition} def
+function normalize( def ) {
+	if ( def.text ) {
+		normalizeTextDefinition( def );
+	}
+
+	if ( def.attributes ) {
+		normalizeAttributes( def.attributes );
+	}
+
+	if ( def.on ) {
+		normalizeListeners( def.on );
+	}
+
+	if ( def.children ) {
+		// Splicing children array inside so no forEach.
+		for ( let i = def.children.length; i--; ) {
+			normalizeTextString( def.children, def.children[ i ], i );
+			normalize( def.children[ i ] );
+		}
+	}
+}
+
+// Normalizes "attributes" section of {@link ui.TemplateDefinition}.
+//
+//		attributes: {
+//			a: 'bar',
+//			b: {@link ui.TemplateBinding},
+//			c: {
+//				value: 'bar'
+//			}
+//		}
+//
+// becomes
+//
+//		attributes: {
+//			a: [ 'bar' ],
+//			b: [ {@link ui.TemplateBinding} ],
+//			c: {
+//				value: [ 'bar' ]
+//			}
+//		}
+//
+// @param {Object} attrs
+function normalizeAttributes( attrs ) {
+	for ( let a in attrs ) {
+		if ( attrs[ a ].value ) {
+			attrs[ a ].value = [].concat( attrs[ a ].value );
+		}
+
+		arrayify( attrs, a );
+	}
+}
+
+// Normalizes "on" section of {@link ui.TemplateDefinition}.
+//
+//		on: {
+//			a: 'bar',
+//			b: {@link ui.TemplateBinding},
+//			c: [ {@link ui.TemplateBinding}, () => { ... } ]
+//		}
+//
+// becomes
+//
+//		on: {
+//			a: [ 'bar' ],
+//			b: [ {@link ui.TemplateBinding} ],
+//			c: [ {@link ui.TemplateBinding}, () => { ... } ]
+//		}
+//
+// @param {Object} listeners
+function normalizeListeners( listeners ) {
+	for ( let l in listeners ) {
+		arrayify( listeners, l );
+	}
+}
+
+// Normalizes "string" text in "children" section of {@link ui.TemplateDefinition}.
+//
+//		children: [
+//			'abc',
+//		]
+//
+// becomes
+//
+//		children: [
+//			{ text: [ 'abc' ] },
+//		]
+//
+// @param {Array} children
+// @param {ui.TemplateDefinition} child
+// @param {Number} index
+function normalizeTextString( children, child, index ) {
+	if ( typeof child == 'string' ) {
+		children.splice( index, 1, {
+			text: [ child ]
+		} );
+	}
+}
+
+// Normalizes text {@link ui.TemplateDefinition}.
+//
+//		children: [
+//			{ text: 'def' },
+//			{ text: {@link ui.TemplateBinding} }
+//		]
+//
+// becomes
+//
+//		children: [
+//			{ text: [ 'def' ] },
+//			{ text: [ {@link ui.TemplateBinding} ] }
+//		]
+//
+// @param {ui.TemplateDefinition} def
+function normalizeTextDefinition( def ) {
+	if ( !Array.isArray( def.text ) ) {
+		def.text = [ def.text ];
+	}
+}
+
+// Wraps an entry in Object in an Array, if not already one.
+//
+//		{
+//			x: 'y',
+//			a: [ 'b' ]
+//		}
+//
+// becomes
+//
+//		{
+//			x: [ 'y' ],
+//			a: [ 'b' ]
+//		}
+//
+// @param {Object} obj
+// @param {String} key
+function arrayify( obj, key ) {
+	if ( !Array.isArray( obj[ key ] ) ) {
+		obj[ key ] = [ obj[ key ] ];
+	}
+}
+
+// A helper which concatenates the value avoiding unwanted
+// leading white spaces.
+//
+// @param {String} prev
+// @param {String} cur
+// @returns {String}
+function arrayValueReducer( prev, cur ) {
+	return prev === '' ?
+			`${cur}`
+		:
+			cur === '' ? `${prev}` : `${prev} ${cur}`;
+}
+
+// Extends one object defined in the following format:
+//
+//		{
+//			key1: [Array1],
+//			key2: [Array2],
+//			...
+//			keyN: [ArrayN]
+//		}
+//
+// with another object of the same data format.
+//
+// @param {Object} obj Base object.
+// @param {Object} ext Object extending base.
+// @returns {String}
+function extendObjectValueArray( obj, ext ) {
+	for ( let a in ext ) {
+		if ( obj[ a ] ) {
+			obj[ a ].push( ...ext[ a ] );
+		} else {
+			obj[ a ] = ext[ a ];
+		}
+	}
+}
+
+// A helper for {@link ui.Template#extend}. Recursively extends {@link ui.Template#definition}
+// with content from another definition. See {@link ui.Template#extend} to learn more.
+//
+// @param {ui.TemplateDefinition} def A base template definition.
+// @param {ui.TemplateDefinition} extDef An extension to existing definition.
+function extendTemplateDefinition( def, extDef ) {
+	if ( extDef.attributes ) {
+		if ( !def.attributes ) {
+			def.attributes = {};
+		}
+
+		extendObjectValueArray( def.attributes, extDef.attributes );
+	}
+
+	if ( extDef.on ) {
+		if ( !def.on ) {
+			def.on = {};
+		}
+
+		extendObjectValueArray( def.on, extDef.on );
+	}
+
+	if ( extDef.text ) {
+		def.text.push( ...extDef.text );
+	}
+
+	if ( extDef.children ) {
+		if ( !def.children || def.children.length != extDef.children.length ) {
+			/**
+			 * The number of children in extended definition does not match.
+			 *
+			 * @error ui-template-extend-children-mismatch
+			 */
+			throw new CKEditorError( 'ui-template-extend-children-mismatch: The number of children in extended definition does not match.' );
+		}
+
+		extDef.children.forEach( ( extChildDef, index ) => {
+			extendTemplateDefinition( def.children[ index ], extChildDef );
+		} );
+	}
+}
+
 /**
- * Definition of {@link Template}.
+ * A definition of {@link ui.Template}.
  * See: {@link ui.TemplateValueSchema}.
  *
- *		{
+ *		new Template( {
  *			tag: 'p',
  *			children: [
  *				{
@@ -324,54 +906,104 @@ function getElementAttributeUpdater( el, attrName, ns = null ) {
  *				...
  *			],
  *			attributes: {
- *				'class': [ 'class-a', 'class-b' ],
- *				id: 'element-id',
- *				style: callback,
+ *				class: {@link ui.TemplateValueSchema},
+ *				id: {@link ui.TemplateValueSchema},
  *				...
  *			},
  *			on: {
- *				'click': 'clicked'
- *				'mouseup': [ 'view-event-a', 'view-event-b', callback ],
- *				'keyup@selector': 'view-event',
- *				'focus@selector': [ 'view-event-a', 'view-event-b', callback ],
+ *				'click': {@link ui.TemplateListenerSchema}
+ *				'keyup@.some-class': {@link ui.TemplateListenerSchema},
  *				...
  *			}
- *		}
+ *		} );
  *
  * @typedef ui.TemplateDefinition
  * @type Object
  * @property {String} tag
- * @property {Array} [children]
- * @property {Object} [attributes]
- * @property {String} [text]
- * @property {Object} [on]
- * @property {Object} _modelBinders
+ * @property {Array.<ui.TemplateDefinition>} [children]
+ * @property {Object.<String,ui.TemplateValueSchema>} [attributes]
+ * @property {String|ui.TemplateValueSchema} [text]
+ * @property {Object.<String,ui.TemplateListenerSchema>} [on]
  */
 
 /**
- * Describes a value of HTMLElement attribute or `textContent`.
- * See: {@link ui.TemplateDefinition}.
+ * Describes a value of HTMLElement attribute or `textContent`. See:
+ *  * {@link ui.TemplateDefinition},
+ *  * {@link ui.Template#bind},
  *
- *		{
+ *		const bind = Template.bind( observableInstance, emitterInstance );
+ *
+ *		new Template( {
  *			tag: 'p',
  *			attributes: {
  *				// Plain String schema.
- *				class: 'class-foo'
+ *				class: 'static-text'
  *
- *				// Object schema, a Model binding.
- *				class: { model: m, attribute: 'foo', callback... }
+ *				// Object schema, an `ObservableMixin` binding.
+ *				class: bind.to( 'foo' )
  *
  *				// Array schema, combines the above.
- *				class: [ 'foo', { model: m, attribute: 'bar' }, 'baz' ],
+ *				class: [
+ *					'static-text',
+ *					bind.to( 'bar', () => { ... } )
+ *				],
  *
  *				// Array schema, with custom namespace.
  *				class: {
  *					ns: 'http://ns.url',
- *					value: [ 'foo', { model: m, attribute: 'bar' }, 'baz' ]
+ *					value: [
+ *						bind.if( 'baz', 'value-when-true' )
+ *						'static-text'
+ *					]
  *				}
  *			}
- *		}
+ *		} );
  *
  * @typedef ui.TemplateValueSchema
  * @type {Object|String|Array}
  */
+
+/**
+ * Describes a listener attached to HTMLElement. See: {@link ui.TemplateDefinition}.
+ *
+ *		new Template( {
+ *			tag: 'p',
+ *			on: {
+ *				// Plain String schema.
+ *				click: 'clicked'
+ *
+ *				// Object schema, an `ObservableMixin` binding.
+ *				click: {@link ui.TemplateBinding}
+ *
+ *				// Array schema, combines the above.
+ *				click: [
+ *					'clicked',
+ *					{@link ui.TemplateBinding}
+ *				],
+ *
+ *				// Array schema, with custom callback.
+ *				// Note: It will work for "click" event on class=".foo" children only.
+ *				'click@.foo': {
+ *					'clicked',
+ *					{@link ui.TemplateBinding},
+ *					() => { ... }
+ *				}
+ *			}
+ *		} );
+ *
+ * @typedef ui.TemplateListenerSchema
+ * @type {Object|String|Array}
+ */
+
+/**
+ * Describes Model binding created via {@link ui.Template#bind}.
+ *
+ * @typedef ui.TemplateBinding
+ * @type Object
+ * @property {utils.ObservableMixin} observable
+ * @property {utils.EmitterMixin} emitter
+ * @property {Symbol} type
+ * @property {String} attribute
+ * @property {String} [valueIfTrue]
+ * @property {Function} [callback]
+ */

+ 11 - 479
packages/ckeditor5-ui/src/view.js

@@ -11,10 +11,6 @@ import Template from './template.js';
 import CKEditorError from '../utils/ckeditorerror.js';
 import DOMEmitterMixin from './domemittermixin.js';
 import mix from '../utils/mix.js';
-import isPlainObject from '../utils/lib/lodash/isPlainObject.js';
-
-const bindToSymbol = Symbol( 'bindTo' );
-const bindIfSymbol = Symbol( 'bindIf' );
 
 /**
  * Basic View class.
@@ -62,10 +58,18 @@ export default class View {
 			idProperty: 'name'
 		} );
 
+		/**
+		 * Shorthand for {@link ui.Template#bind}, bound to {@link ui.View#model}
+		 * and {@link ui.View}.
+		 *
+		 * @method ui.View#bind
+		 */
+		this.bind = Template.bind( this.model, this );
+
 		/**
 		 * Template of this view.
 		 *
-		 * @member {Object} ui.View#template
+		 * @member {ui.Template} ui.View#template
 		 */
 
 		/**
@@ -82,13 +86,6 @@ export default class View {
 		 * @private
 		 * @member {HTMLElement} ui.View.#_element
 		 */
-
-		/**
-		 * An instance of Template to generate {@link ui.View#_el}.
-		 *
-		 * @private
-		 * @member {ui.Template} ui.View#_template
-		 */
 	}
 
 	/**
@@ -107,108 +104,13 @@ export default class View {
 			return null;
 		}
 
-		// Prepare pre–defined listeners.
-		this._extendTemplateWithListenerAttachers( this.template );
-
-		// Prepare pre–defined attribute bindings.
-		this._extendTemplateWithModelBinders( this.template );
-
-		this._template = new Template( this.template );
-
-		return ( this._element = this._template.render() );
+		return ( this._element = this.template.render() );
 	}
 
 	set element( el ) {
 		this._element = el;
 	}
 
-	/**
-	 * And entry point to the interface which allows binding attributes of {@link View#model}
-	 * to the DOM items like HTMLElement attributes or Text Node `textContent`, so their state
-	 * is synchronized with {@link View#model}.
-	 *
-	 * @readonly
-	 * @type ui.ViewModelBinding
-	 */
-	get attributeBinder() {
-		if ( this._attributeBinder ) {
-			return this._attributeBinder;
-		}
-
-		const model = this.model;
-		const binder = {
-			/**
-			 * Binds {@link View#model} to HTMLElement attribute or Text Node `textContent`
-			 * so remains in sync with the Model when it changes.
-			 *
-			 *		this.template = {
-			 *			tag: 'p',
-			 *			attributes: {
-			 *				// class="..." attribute gets bound to this.model.a
-			 *				'class': bind.to( 'a' )
-			 *			},
-			 *			children: [
-			 *				// <p>...</p> gets bound to this.model.b; always `toUpperCase()`.
-			 *				{ text: bind.to( 'b', ( value, node ) => value.toUpperCase() ) }
-			 *			]
-			 *		}
-			 *
-			 * @property {attributeBinder.to}
-			 * @param {String} attribute Name of {@link View#model} used in the binding.
-			 * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
-			 * @return {ui.ViewModelBinding}
-			 */
-			to( attribute, callback ) {
-				return {
-					type: bindToSymbol,
-					model: model,
-					attribute,
-					callback
-				};
-			},
-
-			/**
-			 * Binds {@link View#model} to HTMLElement attribute or Text Node `textContent`
-			 * so remains in sync with the Model when it changes. Unlike {@link View#attributeBinder.to},
-			 * it controls the presence of the attribute/`textContent` depending on the "falseness" of
-			 * {@link View#model} attribute.
-			 *
-			 *		this.template = {
-			 *			tag: 'input',
-			 *			attributes: {
-			 *				// <input checked> this.model.a is not undefined/null/false/''
-			 *				// <input> this.model.a is undefined/null/false
-			 *				checked: bind.if( 'a' )
-			 *			},
-			 *			children: [
-			 *				{
-			 *					// <input>"b-is-not-set"</input> when this.model.b is undefined/null/false/''
-			 *					// <input></input> when this.model.b is not "falsy"
-			 *					text: bind.if( 'b', 'b-is-not-set', ( value, node ) => !value )
-			 *				}
-			 *			]
-			 *		}
-			 *
-			 * @property {attributeBinder.if}
-			 * @param {String} attribute Name of {@link View#model} used in the binding.
-			 * @param {String} [valueIfTrue] Value set when {@link View#model} attribute is not undefined/null/false/''.
-			 * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
-			 * @return {ui.ViewModelBinding}
-			 */
-			if( attribute, valueIfTrue, callback ) {
-				return {
-					type: bindIfSymbol,
-					model: model,
-					attribute,
-					valueIfTrue,
-					callback
-				};
-			}
-		};
-
-		return ( this._attributeBinder = binder );
-	}
-
 	/**
 	 * Initializes the view.
 	 *
@@ -294,42 +196,6 @@ export default class View {
 		this._regionSelectors[ regionName ] = regionSelector;
 	}
 
-	/**
-	 * Applies template to existing DOM element in the context of a View.
-	 *
-	 *		const element = document.createElement( 'div' );
-	 *		const view = new View( new Model( { divClass: 'my-div' } ) );
-	 *
-	 *		view.applyTemplateToElement( element, {
-	 *			attrs: {
-	 *				id: 'first-div',
-	 *				class: view.bindToAttribute( 'divClass' )
-	 *			},
-	 *			on: {
-	 *				click: 'elementClicked' // Will be fired by the View instance.
-	 *			}
-	 *			children: [
-	 *				'Div text.'
-	 *			]
-	 *		} );
-	 *
-	 *		element.outerHTML == "<div id="first-div" class="my-div">Div text.</div>"
-	 *
-	 * See: {@link ui.Template#apply}.
-	 *
-	 * @param {DOMElement} element DOM Element to initialize.
-	 * @param {ui.TemplateDefinition} def Template definition to be applied.
-	 */
-	applyTemplateToElement( element, def ) {
-		// Prepare pre–defined listeners.
-		this._extendTemplateWithListenerAttachers( def );
-
-		// Prepare pre–defined attribute bindings.
-		this._extendTemplateWithModelBinders( def );
-
-		new Template( def ).apply( element );
-	}
-
 	/**
 	 * Destroys the view instance. The process includes:
 	 *
@@ -355,7 +221,7 @@ export default class View {
 		}
 
 		this.model = this.regions = this.template = this.locale = this.t = null;
-		this._regionSelectors = this._element = this._template = null;
+		this._regionSelectors = this._element = null;
 	}
 
 	/**
@@ -381,266 +247,6 @@ export default class View {
 			region.init( regionEl );
 		}
 	}
-
-	/**
-	 * 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.
-	 *
-	 * @protected
-	 * @param {String|Function} evtNameOrCallback Event name to be fired on View or callback to execute.
-	 * @returns {Function} A listener attacher 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 );
-					}
-				}
-			} );
-		};
-	}
-
-	/**
-	 * For given {@link ui.TemplateValueSchema} found by (@link _extendTemplateWithModelBinders} containing
-	 * {@link ui.ViewModelBinding} it returns a function, which when called by {@link Template#render}
-	 * or {@link Template#apply} activates the binding and sets its initial value.
-	 *
-	 * Note: {@link ui.TemplateValueSchema} can be for HTMLElement attributes or Text Node `textContent`.
-	 *
-	 * @protected
-	 * @param {ui.TemplateValueSchema}
-	 * @return {Function}
-	 */
-	_getModelBinder( valueSchema ) {
-		// Normalize attributes with additional data like namespace:
-		// class: { ns: 'abc', value: [ ... ] }
-		if ( valueSchema.value ) {
-			valueSchema = valueSchema.value;
-		}
-
-		valueSchema = normalizeBinderValueSchema( valueSchema );
-
-		// Assembles the value using {@link ui.TemplateValueSchema} and stores it in a form of
-		// an Array. Each entry of an Array corresponds to one of {@link ui.TemplateValueSchema}
-		// items.
-		//
-		// @private
-		// @param {Node} node
-		// @return {Array}
-		const getBoundValue = ( node ) => {
-			let model, modelValue;
-
-			return valueSchema.map( schemaItem => {
-				model = schemaItem.model;
-
-				if ( model ) {
-					modelValue = model[ schemaItem.attribute ];
-
-					if ( schemaItem.callback ) {
-						modelValue = schemaItem.callback( modelValue, node );
-					}
-
-					if ( schemaItem.type === bindIfSymbol ) {
-						return !!modelValue ? schemaItem.valueIfTrue || true : '';
-					} else {
-						return modelValue;
-					}
-				} else {
-					return schemaItem;
-				}
-			} );
-		};
-
-		/**
-		 * Attaches a listener to {@link View#model}, which updates DOM with a value constructed from
-		 * {@link ui.TemplateValueSchema} when {@link View#model} attribute value changes.
-		 *
-		 * This function is called by {@link Template#render} or {@link Template#apply}.
-		 *
-		 * @param {Node} node DOM Node to be updated when {@link View#model} changes.
-		 * @param {Function} domUpdater A function provided by {@link Template} which updates corresponding
-		 * DOM attribute or `textContent`.
-		 */
-		return ( node, domUpdater ) => {
-			// Check if valueSchema is a single bind.if, like:
-			//		{ class: bind.if( 'foo' ) }
-			const isPlainBindIf = valueSchema.length == 1 && valueSchema[ 0 ].type == bindIfSymbol;
-
-			// A function executed each time bound model attribute changes.
-			const onModelChange = () => {
-				let value = getBoundValue( node );
-				let shouldSet;
-
-				if ( isPlainBindIf ) {
-					value = value[ 0 ];
-					shouldSet = value !== '';
-
-					if ( shouldSet ) {
-						value = value === true ? '' : value;
-					}
-				} else {
-					value = value.reduce( binderValueReducer, '' );
-					shouldSet = value;
-				}
-
-				if ( shouldSet ) {
-					domUpdater.set( value );
-				} else {
-					domUpdater.remove();
-				}
-			};
-
-			valueSchema
-				.filter( schemaItem => schemaItem.model )
-				.forEach( schemaItem => {
-					this.listenTo( schemaItem.model, 'change:' + schemaItem.attribute, onModelChange );
-				} );
-
-			// Set initial values.
-			onModelChange();
-		};
-	}
-
-	/**
-	 * Iterates over "attributes" and "text" properties in {@link TemplateDefinition} and
-	 * locates existing {@link ui.ViewModelBinding} created by {@link ui.View#attributeBinder}.
-	 * Then, for each such a binding, it creates corresponding entry in {@link Template#_modelBinders},
-	 * which can be then activated by {@link Template#render} or {@link Template#apply}.
-	 *
-	 * @protected
-	 * @param {ui.TemplateDefinition} def
-	 */
-	_extendTemplateWithModelBinders( def ) {
-		const attributes = def.attributes;
-		const text = def.text;
-		let binders = def._modelBinders;
-		let attrName, attrValue;
-
-		if ( !binders && isPlainObject( def ) ) {
-			Object.defineProperty( def, '_modelBinders', {
-				enumerable: false,
-				writable: true,
-				value: {
-					attributes: {}
-				}
-			} );
-
-			binders = def._modelBinders;
-		}
-
-		if ( attributes ) {
-			for ( attrName in attributes ) {
-				attrValue = attributes[ attrName ];
-
-				if ( hasModelBinding( attrValue ) ) {
-					binders.attributes[ attrName ] = this._getModelBinder( attrValue );
-				}
-			}
-		}
-
-		if ( text && hasModelBinding( text ) ) {
-			binders.text = this._getModelBinder( text );
-		}
-
-		// Repeat recursively for the children.
-		if ( def.children ) {
-			def.children.forEach( this._extendTemplateWithModelBinders, this );
-		}
-	}
-
-	/**
-	 * Iterates over "on" property in {@link TemplateDefinition} to recursively
-	 * replace each listener declaration with a function which, once executed in a context
-	 * of an element, attaches native DOM listener to that element.
-	 *
-	 * @protected
-	 * @param {ui.TemplateDefinition} def Template definition.
-	 */
-	_extendTemplateWithListenerAttachers( def ) {
-		const on = def.on;
-
-		// Don't create attachers if they're already here or in the context of the same (this) View instance.
-		if ( on && ( !on._listenerAttachers || on._listenerView != this ) ) {
-			let domEvtName, evtNameOrCallback;
-
-			Object.defineProperty( on, '_listenerAttachers', {
-				enumerable: false,
-				writable: true,
-				value: {}
-			} );
-
-			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._listenerAttachers[ domEvtName ] = on[ domEvtName ].map( this._getDOMListenerAttacher, this );
-				}
-				// Listeners allow definition with a string containing event name:
-				//
-				//    on: {
-				//       'DOMEventName@selector': 'event1',
-				//       'DOMEventName': 'event2'
-				//       ...
-				//    }
-				else {
-					on._listenerAttachers[ domEvtName ] = this._getDOMListenerAttacher( evtNameOrCallback );
-				}
-			}
-
-			// Set this property to be known that these attachers has already been created
-			// in the context of this particular View instance.
-			Object.defineProperty( on, '_listenerView', {
-				enumerable: false,
-				writable: true,
-				value: this
-			} );
-		}
-
-		// Repeat recursively for the children.
-		if ( def.children ) {
-			def.children.forEach( this._extendTemplateWithListenerAttachers, this );
-		}
-	}
 }
 
 mix( View, DOMEmitterMixin );
@@ -658,77 +264,3 @@ const validSelectorTypes = new Set( [ 'string', 'boolean', 'function' ] );
 function isValidRegionSelector( selector ) {
 	return validSelectorTypes.has( typeof selector ) && selector !== false;
 }
-
-/**
- * Normalizes given {@link ui.TemplateValueSchema} it's always in an Array–like format:
- *
- * 		{ attributeName/text: 'bar' } ->
- * 			{ attributeName/text: [ 'bar' ] }
- *
- * 		{ attributeName/text: { model: ..., modelAttributeName: ..., callback: ... } } ->
- * 			{ attributeName/text: [ { model: ..., modelAttributeName: ..., callback: ... } ] }
- *
- * 		{ attributeName/text: [ 'bar', { model: ..., modelAttributeName: ... }, 'baz' ] }
- *
- * @ignore
- * @private
- * @param {ui.TemplateValueSchema} valueSchema
- * @returns {Array}
- */
-function normalizeBinderValueSchema( valueSchema ) {
-	return Array.isArray( valueSchema ) ? valueSchema : [ valueSchema ];
-}
-
-/**
- * Checks whether given {@link ui.TemplateValueSchema} contains a
- * {@link ui.ViewModelBinding}.
- *
- * @ignore
- * @private
- * @param {ui.TemplateValueSchema} valueSchema
- * @returns {Boolean}
- */
-function hasModelBinding( valueSchema ) {
-	// Normalize attributes with additional data like namespace:
-	// class: { ns: 'abc', value: [ ... ] }
-	if ( valueSchema.value ) {
-		valueSchema = valueSchema.value;
-	}
-
-	if ( Array.isArray( valueSchema ) ) {
-		return valueSchema.some( hasModelBinding );
-	} else if ( valueSchema.model ) {
-		return true;
-	}
-
-	return false;
-}
-
-/**
- * A helper which concatenates the value avoiding unwanted
- * leading white spaces.
- *
- * @ignore
- * @private
- * @param {String} prev
- * @param {String} cur
- * @returns {String}
- */
-function binderValueReducer( prev, cur ) {
-	return prev === '' ?
-			`${cur}`
-		:
-			cur === '' ? `${prev}` : `${prev} ${cur}`;
-}
-
-/**
- * Describes Model binding created by {@link View#attributeBinder}.
- *
- * @typedef ui.ViewModelBinding
- * @type Object
- * @property {Symbol} type
- * @property {ui.Model} model
- * @property {String} attribute
- * @property {String} [valueIfTrue]
- * @property {Function} [callback]
- */

+ 2 - 3
packages/ckeditor5-ui/tests/domemittermixin.js

@@ -9,7 +9,6 @@
 'use strict';
 
 import testUtils from '/tests/ckeditor5/_utils/utils.js';
-import extend from '/ckeditor5/utils/lib/lodash/extend.js';
 import DOMEmitterMixin from '/ckeditor5/ui/domemittermixin.js';
 import EmitterMixin from '/ckeditor5/utils/emittermixin.js';
 
@@ -17,8 +16,8 @@ let emitter, domEmitter, node;
 
 testUtils.createSinonSandbox();
 
-const getEmitterInstance = () => extend( {}, EmitterMixin );
-const getDOMEmitterInstance = () => extend( {}, DOMEmitterMixin );
+const getEmitterInstance = () => Object.create( EmitterMixin );
+const getDOMEmitterInstance = () => Object.create( DOMEmitterMixin );
 const getDOMNodeInstance = () => document.createElement( 'div' );
 
 function updateEmitterInstance() {

+ 4 - 1
packages/ckeditor5-ui/tests/editableui/editableuiview.js

@@ -15,7 +15,10 @@ describe( 'EditableUIView', () => {
 	let model, view, editableElement, locale;
 
 	beforeEach( () => {
-		model = new Model( { isReadOnly: false, isFocused: false } );
+		model = new Model( {
+			isReadOnly: false,
+			isFocused: false
+		} );
 		locale = new Locale( 'en' );
 		view = new EditableUIView( model, locale );
 		editableElement = document.createElement( 'div' );

+ 3 - 2
packages/ckeditor5-ui/tests/region.js

@@ -10,6 +10,7 @@
 
 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;
@@ -121,14 +122,14 @@ function createRegionInstance() {
 	class A extends View {
 		constructor() {
 			super();
-			this.template = { tag: 'a' };
+			this.template = new Template( { tag: 'a' } );
 		}
 	}
 
 	class B extends View {
 		constructor() {
 			super();
-			this.template = { tag: 'b' };
+			this.template = new Template( { tag: 'b' } );
 		}
 	}
 

+ 1601 - 228
packages/ckeditor5-ui/tests/template.js

@@ -10,7 +10,10 @@
 
 import testUtils from '/tests/ckeditor5/_utils/utils.js';
 import Template from '/ckeditor5/ui/template.js';
+import Model from '/ckeditor5/ui/model.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import EmitterMixin from '/ckeditor5/utils/emittermixin.js';
+import DOMEmitterMixin from '/ckeditor5/ui/domemittermixin.js';
 
 testUtils.createSinonSandbox();
 
@@ -18,12 +21,89 @@ let el, text;
 
 describe( 'Template', () => {
 	describe( 'constructor', () => {
-		it( 'accepts the definition', () => {
+		it( 'accepts template definition', () => {
 			const def = {
 				tag: 'p'
 			};
 
-			expect( new Template( def ).definition ).to.equal( def );
+			expect( new Template( def ).definition ).to.not.equal( def );
+			expect( new Template( def ).definition.tag ).to.equal( 'p' );
+		} );
+
+		it( 'normalizes template definition', () => {
+			const bind = Template.bind( new Model( {} ), Object.create( DOMEmitterMixin ) );
+			const tpl = new Template( {
+				tag: 'p',
+				attributes: {
+					a: 'foo',
+					b: [ 'bar', 'baz' ],
+					c: {
+						ns: 'abc',
+						value: bind.to( 'qux' )
+					}
+				},
+				children: [
+					{
+						text: 'content'
+					},
+					{
+						text: bind.to( 'x' )
+					},
+					'abc',
+					{
+						text: [ 'a', 'b' ]
+					}
+				],
+				on: {
+					'a@span': bind.to( 'b' ),
+					'b@span': bind.to( () => {} ),
+					'c@span': [
+						bind.to( 'c' ),
+						bind.to( () => {} )
+					]
+				}
+			} );
+
+			const def = tpl.definition;
+
+			expect( def.attributes.a[ 0 ] ).to.equal( 'foo' );
+			expect( def.attributes.b[ 0 ] ).to.equal( 'bar' );
+			expect( def.attributes.b[ 1 ] ).to.equal( 'baz' );
+			expect( def.attributes.c[ 0 ].value[ 0 ].type ).to.be.a( 'symbol' );
+
+			expect( def.children[ 0 ].text[ 0 ] ).to.equal( 'content' );
+			expect( def.children[ 1 ].text[ 0 ].type ).to.be.a( 'symbol' );
+			expect( def.children[ 2 ].text[ 0 ] ).to.equal( 'abc' );
+			expect( def.children[ 3 ].text[ 0 ] ).to.equal( 'a' );
+			expect( def.children[ 3 ].text[ 1 ] ).to.equal( 'b' );
+
+			expect( def.on[ 'a@span' ][ 0 ].type ).to.be.a( 'symbol' );
+			expect( def.on[ 'b@span' ][ 0 ].type ).to.be.a( 'symbol' );
+			expect( def.on[ 'c@span' ][ 0 ].type ).to.be.a( 'symbol' );
+			expect( def.on[ 'c@span' ][ 1 ].type ).to.be.a( 'symbol' );
+		} );
+
+		it( 'does not modify passed definition', () => {
+			const def = {
+				tag: 'p',
+				attributes: {
+					a: 'foo',
+				},
+				children: [
+					{
+						tag: 'span'
+					}
+				]
+			};
+			const tpl = new Template( def );
+
+			expect( def ).to.not.equal( tpl.definition );
+			expect( def.attributes ).to.not.equal( tpl.definition.attributes );
+			expect( def.children ).to.not.equal( tpl.definition.children );
+			expect( def.children[ 0 ] ).to.not.equal( tpl.definition.children[ 0 ] );
+
+			expect( tpl.definition.attributes.a[ 0 ] ).to.equal( 'foo' );
+			expect( def.attributes.a ).to.equal( 'foo' );
 		} );
 	} );
 
@@ -41,7 +121,7 @@ describe( 'Template', () => {
 			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
 		} );
 
-		it( 'creates a HTMLElement', () => {
+		it( 'creates HTMLElement', () => {
 			const el = new Template( {
 				tag: 'p',
 			} ).render();
@@ -107,7 +187,7 @@ describe( 'Template', () => {
 			const el = new Template( {
 				tag: 'p',
 				attributes: {
-					'class': {
+					class: {
 						ns: 'foo',
 						value: [ 'a', 'b' ]
 					},
@@ -171,208 +251,86 @@ describe( 'Template', () => {
 		} );
 
 		it( 'creates multiple child Text Nodes', () => {
-			const el = new Template( {
-				tag: 'p',
-				children: [ 'a', 'b', { text: 'c' }, 'd' ]
-			} ).render();
-
-			expect( el.childNodes ).to.have.length( 4 );
-			expect( el.outerHTML ).to.be.equal( '<p>abcd</p>' );
-		} );
-
-		it( 'activates listener attachers – root', () => {
-			const spy1 = testUtils.sinon.spy();
-			const spy2 = testUtils.sinon.spy();
-			const spy3 = testUtils.sinon.spy();
-
-			const el = new Template( {
-				tag: 'p',
-				on: {
-					_listenerAttachers: {
-						foo: spy1,
-						baz: [ spy2, spy3 ]
-					}
-				}
-			} ).render();
-
-			sinon.assert.calledWithExactly( spy1, el, 'foo', null );
-			sinon.assert.calledWithExactly( spy2, el, 'baz', null );
-			sinon.assert.calledWithExactly( spy3, el, 'baz', null );
-		} );
-
-		it( 'activates listener attachers – children', () => {
-			const spy = testUtils.sinon.spy();
-			const el = new Template( {
-				tag: 'p',
-				children: [
-					{
-						tag: 'span',
-						on: {
-							_listenerAttachers: {
-								bar: spy
-							}
-						}
-					}
-				],
-			} ).render();
-
-			sinon.assert.calledWithExactly( spy, el.firstChild, 'bar', null );
-		} );
-
-		it( 'activates listener attachers – DOM selectors', () => {
-			const spy1 = testUtils.sinon.spy();
-			const spy2 = testUtils.sinon.spy();
-			const spy3 = testUtils.sinon.spy();
-			const spy4 = testUtils.sinon.spy();
-
 			const el = new Template( {
 				tag: 'p',
 				children: [
-					{
-						tag: 'span',
-						attributes: {
-							'id': 'x'
-						}
-					},
-					{
-						tag: 'span',
-						attributes: {
-							'class': 'y'
-						},
-						on: {
-							_listenerAttachers: {
-								'bar@p': spy2
-							}
-						}
-					},
-				],
-				on: {
-					_listenerAttachers: {
-						'foo@span': spy1,
-						'baz@.y': [ spy3, spy4 ]
-					}
-				}
+					'a',
+					'b',
+					{ text: 'c' },
+					'd',
+					{ text: [ 'e', 'f' ] }
+				]
 			} ).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' );
+			expect( el.childNodes ).to.have.length( 5 );
+			expect( el.outerHTML ).to.be.equal( '<p>abcdef</p>' );
 		} );
 
-		it( 'activates model bindings – attributes', () => {
-			const spy1 = testUtils.sinon.spy();
-			const spy2 = testUtils.sinon.spy();
+		it( 'activates model bindings – root', () => {
+			const observable = new Model( {
+				foo: 'bar'
+			} );
 
+			const emitter = Object.create( EmitterMixin );
+			const bind = Template.bind( observable, emitter );
 			const el = new Template( {
-				tag: 'p',
+				tag: 'div',
 				attributes: {
-					'class': {}
-				},
-				children: [
-					{
-						tag: 'span',
-						attributes: {
-							id: {}
-						},
-						_modelBinders: {
-							attributes: {
-								id: spy2
-							}
-						}
-					}
-				],
-				_modelBinders: {
-					attributes: {
-						class: spy1
-					}
+					class: bind.to( 'foo' )
 				}
 			} ).render();
 
-			sinon.assert.calledWithExactly( spy1, el, sinon.match.object );
-			sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.object );
+			expect( el.getAttribute( 'class' ) ).to.equal( 'bar' );
+
+			observable.foo = 'baz';
+			expect( el.getAttribute( 'class' ) ).to.equal( 'baz' );
 		} );
 
-		it( 'activates model bindings – Text Node', () => {
-			const spy1 = testUtils.sinon.spy();
-			const spy2 = testUtils.sinon.spy();
+		it( 'activates model bindings – children', () => {
+			const observable = new Model( {
+				foo: 'bar'
+			} );
 
+			const emitter = Object.create( EmitterMixin );
+			const bind = Template.bind( observable, emitter );
 			const el = new Template( {
-				tag: 'p',
+				tag: 'div',
 				children: [
-					{
-						text: {},
-						_modelBinders: {
-							text: spy1
-						}
-					},
 					{
 						tag: 'span',
 						children: [
 							{
-								text: {},
-								_modelBinders: {
-									text: spy2
-								}
+								text: [
+									bind.to( 'foo' ),
+									'static'
+								]
 							}
 						]
 					}
 				]
 			} ).render();
 
-			sinon.assert.calledWithExactly( spy1, el.firstChild, sinon.match.object );
-			sinon.assert.calledWithExactly( spy2, el.lastChild.firstChild, sinon.match.object );
-		} );
-
-		it( 'uses DOM updater – attributes', () => {
-			const spy = testUtils.sinon.spy();
-			const el = new Template( {
-				tag: 'p',
-				attributes: {
-					'class': {}
-				},
-				_modelBinders: {
-					attributes: {
-						class: spy
-					}
-				}
-			} ).render();
-
-			// Check whether DOM updater is correct.
-			spy.firstCall.args[ 1 ].set( 'x' );
-			expect( el.outerHTML ).to.be.equal( '<p class="x"></p>' );
-
-			spy.firstCall.args[ 1 ].remove();
-			expect( el.outerHTML ).to.be.equal( '<p></p>' );
-		} );
-
-		it( 'uses DOM updater – text', () => {
-			const spy = testUtils.sinon.spy();
-			const el = new Template( {
-				tag: 'p',
-				children: [
-					{
-						text: {},
-						_modelBinders: {
-							text: spy
-						}
-					}
-				],
-			} ).render();
-
-			// Check whether DOM updater is correct.
-			spy.firstCall.args[ 1 ].set( 'x' );
-			expect( el.outerHTML ).to.be.equal( '<p>x</p>' );
+			expect( el.firstChild.textContent ).to.equal( 'bar static' );
 
-			spy.firstCall.args[ 1 ].remove();
-			expect( el.outerHTML ).to.be.equal( '<p></p>' );
+			observable.foo = 'baz';
+			expect( el.firstChild.textContent ).to.equal( 'baz static' );
 		} );
 	} );
 
 	describe( 'apply', () => {
+		let observable, domEmitter, bind;
+
 		beforeEach( () => {
 			el = document.createElement( 'div' );
 			text = document.createTextNode( '' );
+
+			observable = new Model( {
+				foo: 'bar',
+				baz: 'qux'
+			} );
+
+			domEmitter = Object.create( DOMEmitterMixin );
+			bind = Template.bind( observable, domEmitter );
 		} );
 
 		it( 'throws when wrong template definition', () => {
@@ -420,7 +378,7 @@ describe( 'Template', () => {
 			expect( el.outerHTML ).to.be.equal( '<div class="a b" x="bar"></div>' );
 		} );
 
-		it( 'applies doesn\'t apply new child to an HTMLElement – Text Node', () => {
+		it( 'doesn\'t apply new child to an HTMLElement – Text Node', () => {
 			new Template( {
 				tag: 'div',
 				children: [ 'foo' ]
@@ -429,7 +387,7 @@ describe( 'Template', () => {
 			expect( el.outerHTML ).to.be.equal( '<div></div>' );
 		} );
 
-		it( 'applies doesn\'t apply new child to an HTMLElement – HTMLElement', () => {
+		it( 'doesn\'t apply new child to an HTMLElement – HTMLElement', () => {
 			new Template( {
 				tag: 'div',
 				children: [
@@ -476,82 +434,1497 @@ describe( 'Template', () => {
 			expect( el.outerHTML ).to.be.equal( '<div class="parent">Children: <span class="child"></span></div>' );
 		} );
 
-		it( 'activates listener attachers – root', () => {
-			const spy = testUtils.sinon.spy();
+		it( 'should work for deep DOM structure', () => {
+			const childA = document.createElement( 'a' );
+			const childB = document.createElement( 'b' );
 
-			new Template( {
-				tag: 'div',
-				on: {
-					_listenerAttachers: {
-						click: spy
-					}
-				}
-			} ).apply( el );
+			childA.textContent = 'anchor';
+			childB.textContent = 'bold';
 
-			sinon.assert.calledWithExactly( spy, el, 'click', null );
-		} );
+			el.appendChild( childA );
+			el.appendChild( childB );
 
-		it( 'activates listener attachers – children', () => {
-			const spy = testUtils.sinon.spy();
-			el.appendChild( document.createElement( 'span' ) );
+			expect( el.outerHTML ).to.equal( '<div><a>anchor</a><b>bold</b></div>' );
+
+			const spy1 = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+			const spy3 = testUtils.sinon.spy();
+
+			observable.on( 'ku', spy1 );
+			observable.on( 'kd', spy2 );
+			observable.on( 'mo', spy3 );
 
 			new Template( {
 				tag: 'div',
 				children: [
 					{
-						tag: 'span',
+						tag: 'a',
 						on: {
-							_listenerAttachers: {
-								click: spy
-							}
-						}
-					}
-				]
+							keyup: bind.to( 'ku' )
+						},
+						attributes: {
+							class: bind.to( 'foo', val => 'applied-A-' + val ),
+							id: 'applied-A'
+						},
+						children: [ 'Text applied to childA.' ]
+					},
+					{
+						tag: 'b',
+						on: {
+							keydown: bind.to( 'kd' )
+						},
+						attributes: {
+							class: bind.to( 'baz', val => 'applied-B-' + val ),
+							id: 'applied-B'
+						},
+						children: [ 'Text applied to childB.' ]
+					},
+					'Text which is not to be applied because it does NOT exist in original element.'
+				],
+				on: {
+					'mouseover@a': bind.to( 'mo' )
+				},
+				attributes: {
+					id: bind.to( 'foo', val => val.toUpperCase() ),
+					class: bind.to( 'baz', val => 'applied-parent-' + val )
+				}
 			} ).apply( el );
 
-			sinon.assert.calledWithExactly( spy, el.firstChild, 'click', null );
+			expect( el.outerHTML ).to.equal( '<div id="BAR" class="applied-parent-qux">' +
+				'<a class="applied-A-bar" id="applied-A">Text applied to childA.</a>' +
+				'<b class="applied-B-qux" id="applied-B">Text applied to childB.</b>' +
+			'</div>' );
+
+			observable.foo = 'updated';
+
+			expect( el.outerHTML ).to.equal( '<div id="UPDATED" class="applied-parent-qux">' +
+				'<a class="applied-A-updated" id="applied-A">Text applied to childA.</a>' +
+				'<b class="applied-B-qux" id="applied-B">Text applied to childB.</b>' +
+			'</div>' );
+
+			document.body.appendChild( el );
+
+			// Test "mouseover@a".
+			dispatchEvent( el, 'mouseover' );
+			dispatchEvent( childA, 'mouseover' );
+
+			// Test "keyup".
+			dispatchEvent( childA, 'keyup' );
+
+			// Test "keydown".
+			dispatchEvent( childB, 'keydown' );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledOnce( spy2 );
+			sinon.assert.calledOnce( spy3 );
 		} );
+	} );
 
-		it( 'activates model bindings – root', () => {
-			const spy = testUtils.sinon.spy();
+	describe( 'bind', () => {
+		it( 'returns object', () => {
+			expect( Template.bind() ).to.be.an( 'object' );
+		} );
 
-			new Template( {
-				tag: 'div',
-				attributes: {
-					class: {}
-				},
-				_modelBinders: {
-					attributes: {
-						class: spy
-					}
-				}
-			} ).apply( el );
+		it( 'provides "to" and "if" interface', () => {
+			const bind = Template.bind();
 
-			sinon.assert.calledWithExactly( spy, el, sinon.match.object );
+			expect( bind ).to.have.keys( 'to', 'if' );
+			expect( bind.to ).to.be.a( 'function' );
+			expect( bind.if ).to.be.a( 'function' );
 		} );
 
-		it( 'activates model bindings – children', () => {
-			const spy = testUtils.sinon.spy();
-			el.appendChild( document.createElement( 'span' ) );
+		describe( 'event', () => {
+			let observable, domEmitter, bind;
 
-			new Template( {
-				tag: 'div',
-				children: [
-					{
-						tag: 'span',
-						attributes: {
-							class: {}
-						},
-						_modelBinders: {
+			beforeEach( () => {
+				observable = new Model( {
+					foo: 'bar',
+					baz: 'qux'
+				} );
+
+				domEmitter = Object.create( DOMEmitterMixin );
+				bind = Template.bind( observable, domEmitter );
+			} );
+
+			it( 'accepts plain binding', () => {
+				const spy = testUtils.sinon.spy();
+
+				setElement( {
+					tag: 'p',
+					on: {
+						x: bind.to( 'a' ),
+					}
+				} );
+
+				observable.on( 'a', spy );
+				dispatchEvent( el, 'x' );
+
+				sinon.assert.calledWithExactly( spy,
+					sinon.match.has( 'name', 'a' ),
+					sinon.match.has( 'target', el )
+				);
+			} );
+
+			it( 'accepts an array of event bindings', () => {
+				const spy1 = testUtils.sinon.spy();
+				const spy2 = testUtils.sinon.spy();
+
+				setElement( {
+					tag: 'p',
+					on: {
+						x: [
+							bind.to( 'a' ),
+							bind.to( 'b' )
+						]
+					}
+				} );
+
+				observable.on( 'a', spy1 );
+				observable.on( 'b', spy2 );
+				dispatchEvent( el, 'x' );
+
+				sinon.assert.calledWithExactly( spy1,
+					sinon.match.has( 'name', 'a' ),
+					sinon.match.has( 'target', el )
+				);
+				sinon.assert.calledWithExactly( spy2,
+					sinon.match.has( 'name', 'b' ),
+					sinon.match.has( 'target', el )
+				);
+			} );
+
+			it( 'accepts DOM selectors', () => {
+				const spy1 = testUtils.sinon.spy();
+				const spy2 = testUtils.sinon.spy();
+				const spy3 = testUtils.sinon.spy();
+
+				setElement( {
+					tag: 'p',
+					children: [
+						{
+							tag: 'span',
 							attributes: {
-								class: spy
+								'class': 'y',
+							},
+							on: {
+								'test@p': bind.to( 'c' )
 							}
+						},
+						{
+							tag: 'div',
+							children: [
+								{
+									tag: 'span',
+									attributes: {
+										'class': 'y',
+									}
+								}
+							],
 						}
+					],
+					on: {
+						'test@.y': bind.to( 'a' ),
+						'test@div': bind.to( 'b' )
 					}
-				]
-			} ).apply( el );
+				} );
 
-			sinon.assert.calledWithExactly( spy, el.firstChild, sinon.match.object );
-		} );
-	} );
-} );
+				observable.on( 'a', spy1 );
+				observable.on( 'b', spy2 );
+				observable.on( 'c', spy3 );
+
+				// Test "test@p".
+				dispatchEvent( el, 'test' );
+
+				sinon.assert.callCount( spy1, 0 );
+				sinon.assert.callCount( spy2, 0 );
+				sinon.assert.callCount( spy3, 0 );
+
+				// Test "test@.y".
+				dispatchEvent( el.firstChild, 'test' );
+
+				expect( spy1.firstCall.calledWithExactly(
+					sinon.match.has( 'name', 'a' ),
+					sinon.match.has( 'target', el.firstChild )
+				) ).to.be.true;
+
+				sinon.assert.callCount( spy2, 0 );
+				sinon.assert.callCount( spy3, 0 );
+
+				// Test "test@div".
+				dispatchEvent( el.lastChild, 'test' );
+
+				sinon.assert.callCount( spy1, 1 );
+
+				expect( spy2.firstCall.calledWithExactly(
+					sinon.match.has( 'name', 'b' ),
+					sinon.match.has( 'target', el.lastChild )
+				) ).to.be.true;
+
+				sinon.assert.callCount( spy3, 0 );
+
+				// Test "test@.y".
+				dispatchEvent( el.lastChild.firstChild, 'test' );
+
+				expect( spy1.secondCall.calledWithExactly(
+					sinon.match.has( 'name', 'a' ),
+					sinon.match.has( 'target', el.lastChild.firstChild )
+				) ).to.be.true;
+
+				sinon.assert.callCount( spy2, 1 );
+				sinon.assert.callCount( spy3, 0 );
+			} );
+
+			it( 'accepts function callbacks', () => {
+				const spy1 = testUtils.sinon.spy();
+				const spy2 = testUtils.sinon.spy();
+
+				setElement( {
+					tag: 'p',
+					children: [
+						{
+							tag: 'span'
+						}
+					],
+					on: {
+						x: bind.to( spy1 ),
+						'y@span': [
+							bind.to( spy2 ),
+							bind.to( 'c' )
+						]
+					}
+				} );
+
+				dispatchEvent( el, 'x' );
+				dispatchEvent( el.firstChild, 'y' );
+
+				sinon.assert.calledWithExactly( spy1,
+					sinon.match.has( 'target', el )
+				);
+
+				sinon.assert.calledWithExactly( spy2,
+					sinon.match.has( 'target', el.firstChild )
+				);
+			} );
+
+			it( 'supports event delegation', () => {
+				const spy = testUtils.sinon.spy();
+
+				setElement( {
+					tag: 'p',
+					children: [
+						{
+							tag: 'span'
+						}
+					],
+					on: {
+						x: bind.to( 'a' ),
+					}
+				} );
+
+				observable.on( 'a', spy );
+
+				dispatchEvent( el.firstChild, 'x' );
+				sinon.assert.calledWithExactly( spy,
+					sinon.match.has( 'name', 'a' ),
+					sinon.match.has( 'target', el.firstChild )
+				);
+			} );
+
+			it( 'works for future elements', () => {
+				const spy = testUtils.sinon.spy();
+
+				setElement( {
+					tag: 'p',
+					on: {
+						'test@div': bind.to( 'a' )
+					}
+				} );
+
+				observable.on( 'a', spy );
+
+				const div = document.createElement( 'div' );
+				el.appendChild( div );
+
+				dispatchEvent( div, 'test' );
+				sinon.assert.calledWithExactly( spy, sinon.match.has( 'name', 'a' ), sinon.match.has( 'target', div ) );
+			} );
+		} );
+
+		describe( 'model', () => {
+			let observable, emitter, bind;
+
+			beforeEach( () => {
+				observable = new Model( {
+					foo: 'bar',
+					baz: 'qux'
+				} );
+
+				emitter = Object.create( EmitterMixin );
+				bind = Template.bind( observable, emitter );
+			} );
+
+			describe( 'to', () => {
+				it( 'returns an object which describes the binding', () => {
+					const spy = testUtils.sinon.spy();
+					const binding = bind.to( 'foo', spy );
+
+					expect( spy.called ).to.be.false;
+					expect( binding ).to.have.keys( [ 'type', 'observable', 'eventNameOrFunction', 'emitter', 'attribute', 'callback' ] );
+					expect( binding.observable ).to.equal( observable );
+					expect( binding.callback ).to.equal( spy );
+					expect( binding.attribute ).to.equal( 'foo' );
+				} );
+
+				it( 'allows binding attribute to the observable – simple (HTMLElement attribute)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo' )
+						},
+						children: [ 'abc' ]
+					} );
+
+					expect( el.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+
+					observable.foo = 'baz';
+					expect( el.outerHTML ).to.equal( '<p class="baz">abc</p>' );
+					expect( el.attributes.getNamedItem( 'class' ).namespaceURI ).to.be.null;
+				} );
+
+				it( 'allows binding attribute to the observable – simple (Text Node)', () => {
+					setElement( {
+						tag: 'p',
+						children: [
+							{
+								text: bind.to( 'foo' )
+							}
+						]
+					} );
+
+					expect( el.outerHTML ).to.equal( '<p>bar</p>' );
+
+					observable.foo = 'baz';
+					expect( el.outerHTML ).to.equal( '<p>baz</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value processing', () => {
+					const callback = value => value > 0 ? 'positive' : 'negative';
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo', callback )
+						},
+						children: [
+							{
+								text: bind.to( 'foo', callback )
+							}
+						]
+					} );
+
+					observable.foo = 3;
+					expect( el.outerHTML ).to.equal( '<p class="positive">positive</p>' );
+
+					observable.foo = -7;
+					expect( el.outerHTML ).to.equal( '<p class="negative">negative</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value processing (use Node)', () => {
+					const callback = ( value, node ) => {
+						return ( !!node.tagName && value > 0 ) ? 'HTMLElement positive' : '';
+					};
+
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo', callback )
+						},
+						children: [
+							{
+								text: bind.to( 'foo', callback )
+							}
+						]
+					} );
+
+					observable.foo = 3;
+					expect( el.outerHTML ).to.equal( '<p class="HTMLElement positive"></p>' );
+
+					observable.foo = -7;
+					expect( el.outerHTML ).to.equal( '<p></p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – custom callback', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo', ( value, el ) => {
+								el.innerHTML = value;
+
+								if ( value == 'changed' ) {
+									return value;
+								}
+							} )
+						}
+					} );
+
+					observable.foo = 'moo';
+					expect( el.outerHTML ).to.equal( '<p class="undefined">moo</p>' );
+
+					observable.foo = 'changed';
+					expect( el.outerHTML ).to.equal( '<p class="changed">changed</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – array of bindings (HTMLElement attribute)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': [
+								'ck-class',
+								bind.to( 'foo' ),
+								bind.to( 'baz' ),
+								bind.to( 'foo', value => `foo-is-${value}` ),
+								'ck-end'
+							]
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'a';
+					observable.baz = 'b';
+					expect( el.outerHTML ).to.equal( '<p class="ck-class a b foo-is-a ck-end">abc</p>' );
+
+					observable.foo = 'c';
+					observable.baz = 'd';
+					expect( el.outerHTML ).to.equal( '<p class="ck-class c d foo-is-c ck-end">abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – array of bindings (Text Node)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+						},
+						children: [
+							{
+								text: [
+									'ck-class',
+									bind.to( 'foo' ),
+									bind.to( 'baz' ),
+									bind.to( 'foo', value => `foo-is-${value}` ),
+									'ck-end'
+								]
+							}
+						]
+					} );
+
+					observable.foo = 'a';
+					observable.baz = 'b';
+					expect( el.outerHTML ).to.equal( '<p>ck-class a b foo-is-a ck-end</p>' );
+
+					observable.foo = 'c';
+					observable.baz = 'd';
+					expect( el.outerHTML ).to.equal( '<p>ck-class c d foo-is-c ck-end</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – falsy values', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo' )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( el.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+
+					observable.foo = false;
+					expect( el.outerHTML ).to.equal( '<p class="false">abc</p>' );
+
+					observable.foo = null;
+					expect( el.outerHTML ).to.equal( '<p class="null">abc</p>' );
+
+					observable.foo = undefined;
+					expect( el.outerHTML ).to.equal( '<p class="undefined">abc</p>' );
+
+					observable.foo = 0;
+					expect( el.outerHTML ).to.equal( '<p class="0">abc</p>' );
+
+					observable.foo = '';
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – a custom namespace', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							class: {
+								ns: 'foo',
+								value: bind.to( 'foo' )
+							},
+							custom: {
+								ns: 'foo',
+								value: [
+									bind.to( 'foo' ),
+									bind.to( 'baz' )
+								]
+							}
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( el.outerHTML ).to.equal( '<p class="bar" custom="bar qux">abc</p>' );
+					expect( el.attributes.getNamedItem( 'class' ).namespaceURI ).to.equal( 'foo' );
+
+					observable.foo = 'baz';
+					expect( el.outerHTML ).to.equal( '<p class="baz" custom="baz qux">abc</p>' );
+					expect( el.attributes.getNamedItem( 'class' ).namespaceURI ).to.equal( 'foo' );
+				} );
+			} );
+
+			describe( 'if', () => {
+				it( 'returns an object which describes the binding', () => {
+					const spy = testUtils.sinon.spy();
+					const binding = bind.if( 'foo', 'whenTrue', spy );
+
+					expect( spy.called ).to.be.false;
+					expect( binding ).to.have.keys( [ 'type', 'observable', 'emitter', 'attribute', 'callback', 'valueIfTrue' ] );
+					expect( binding.observable ).to.equal( observable );
+					expect( binding.callback ).to.equal( spy );
+					expect( binding.attribute ).to.equal( 'foo' );
+					expect( binding.valueIfTrue ).to.equal( 'whenTrue' );
+				} );
+
+				it( 'allows binding attribute to the observable – presence of an attribute (HTMLElement attribute)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo' )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = true;
+					expect( el.outerHTML ).to.equal( '<p class="">abc</p>' );
+
+					observable.foo = false;
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = 'bar';
+					expect( el.outerHTML ).to.equal( '<p class="">abc</p>' );
+				} );
+
+				// TODO: Is this alright? It makes sense but it's pretty useless. Text Node cannot be
+				// removed just like an attribute of some HTMLElement.
+				it( 'allows binding attribute to the observable – presence of an attribute (Text Node)', () => {
+					setElement( {
+						tag: 'p',
+						children: [
+							{
+								text: bind.if( 'foo' )
+							}
+						]
+					} );
+
+					observable.foo = true;
+					expect( el.outerHTML ).to.equal( '<p></p>' );
+
+					observable.foo = false;
+					expect( el.outerHTML ).to.equal( '<p></p>' );
+
+					observable.foo = 'bar';
+					expect( el.outerHTML ).to.equal( '<p></p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value of an attribute (HTMLElement attribute)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'bar' )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( el.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+
+					observable.foo = false;
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = 64;
+					expect( el.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value of an attribute (Text Node)', () => {
+					setElement( {
+						tag: 'p',
+						children: [
+							{
+								text: bind.if( 'foo', 'bar' )
+							}
+						]
+					} );
+
+					observable.foo = 'bar';
+					expect( el.outerHTML ).to.equal( '<p>bar</p>' );
+
+					observable.foo = false;
+					expect( el.outerHTML ).to.equal( '<p></p>' );
+
+					observable.foo = 64;
+					expect( el.outerHTML ).to.equal( '<p>bar</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – array of bindings (HTMLElement attribute)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': [
+								'ck-class',
+								bind.if( 'foo', 'foo-set' ),
+								bind.if( 'bar', 'bar-not-set', ( value ) => !value ),
+								'ck-end'
+							]
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = observable.bar = true;
+					expect( el.outerHTML ).to.equal( '<p class="ck-class foo-set ck-end">abc</p>' );
+
+					observable.foo = observable.bar = false;
+					expect( el.outerHTML ).to.equal( '<p class="ck-class bar-not-set ck-end">abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value of an attribute processed by a callback', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'there–is–no–foo', value => !value )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = false;
+					expect( el.outerHTML ).to.equal( '<p class="there–is–no–foo">abc</p>' );
+
+					observable.foo = 64;
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – value of an attribute processed by a callback (use Node)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'eqls-tag-name', ( value, el ) => el.tagName === value )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = 'P';
+					expect( el.outerHTML ).to.equal( '<p class="eqls-tag-name">abc</p>' );
+
+					observable.foo = 64;
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+				} );
+
+				it( 'allows binding attribute to the observable – falsy values', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'foo-is-set' )
+						},
+						children: [ 'abc' ]
+					} );
+
+					observable.foo = 'bar';
+					expect( el.outerHTML ).to.equal( '<p class="foo-is-set">abc</p>' );
+
+					observable.foo = false;
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = null;
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = undefined;
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = '';
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+
+					observable.foo = 0;
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
+				} );
+			} );
+
+			it( 'works with Template#apply() – root element', () => {
+				new Template( {
+					tag: 'div',
+					attributes: {
+						class: bind.to( 'foo' )
+					}
+				} ).apply( el );
+
+				expect( el.getAttribute( 'class' ) ).to.equal( 'bar' );
+
+				observable.foo = 'baz';
+				expect( el.getAttribute( 'class' ) ).to.equal( 'baz' );
+			} );
+
+			it( 'works with Template#apply() – children', () => {
+				const el = document.createElement( 'div' );
+				const child = document.createElement( 'span' );
+
+				child.textContent = 'foo';
+				el.appendChild( child );
+
+				new Template( {
+					tag: 'div',
+					children: [
+						{
+							tag: 'span',
+							children: [
+								{
+									text: bind.to( 'foo' )
+								}
+							]
+						}
+					]
+				} ).apply( el );
+
+				expect( child.textContent ).to.equal( 'bar' );
+
+				observable.foo = 'baz';
+				expect( child.textContent ).to.equal( 'baz' );
+			} );
+		} );
+	} );
+
+	describe( 'extend', () => {
+		let observable, emitter, bind;
+
+		beforeEach( () => {
+			observable = new Model( {
+				foo: 'bar',
+				baz: 'qux'
+			} );
+
+			emitter = Object.create( DOMEmitterMixin );
+			bind = Template.bind( observable, emitter );
+		} );
+
+		it( 'does not modify passed definition', () => {
+			const def = {
+				tag: 'p',
+				attributes: {
+					a: 'foo',
+				}
+			};
+			const ext = {
+				attributes: {
+					b: 'bar'
+				}
+			};
+			const tpl = new Template( def );
+
+			Template.extend( tpl, ext );
+
+			expect( def.attributes.a ).to.equal( 'foo' );
+			expect( ext.attributes.b ).to.equal( 'bar' );
+
+			expect( tpl.definition.attributes.a[ 0 ] ).to.equal( 'foo' );
+			expect( tpl.definition.attributes.b[ 0 ] ).to.equal( 'bar' );
+		} );
+
+		describe( 'attributes', () => {
+			it( 'extends existing - simple', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: 'b'
+						}
+					},
+					{
+						attributes: {
+							a: 'c'
+						}
+					},
+					'<p a="b c"></p>'
+				);
+			} );
+
+			it( 'extends existing - complex #1', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: [ 'b', 'c' ]
+						}
+					},
+					{
+						attributes: {
+							a: 'd'
+						}
+					},
+					'<p a="b c d"></p>'
+				);
+			} );
+
+			it( 'extends existing - complex #2', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: {
+								value: 'b'
+							}
+						}
+					},
+					{
+						attributes: {
+							a: [ 'c', 'd' ]
+						}
+					},
+					'<p a="b c d"></p>'
+				);
+			} );
+
+			it( 'extends existing - complex #3', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: [ 'b' ]
+						}
+					},
+					{
+						attributes: {
+							a: [ 'c', 'd' ]
+						}
+					},
+					'<p a="b c d"></p>'
+				);
+			} );
+
+			it( 'extends existing - bindings #1', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: bind.to( 'foo' )
+						}
+					},
+					{
+						attributes: {
+							a: [ 'c', 'd' ]
+						}
+					},
+					'<p a="bar c d"></p>'
+				);
+
+				observable.foo = 'baz';
+
+				expect( el.outerHTML ).to.equal( '<p a="baz c d"></p>' );
+			} );
+
+			it( 'extends existing - bindings #2', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: [ 'b', bind.to( 'foo' ) ]
+						}
+					},
+					{
+						attributes: {
+							a: [ 'c', bind.to( 'baz' ) ]
+						}
+					},
+					'<p a="b bar c qux"></p>'
+				);
+
+				observable.foo = 'abc';
+				observable.baz = 'def';
+
+				expect( el.outerHTML ).to.equal( '<p a="b abc c def"></p>' );
+			} );
+
+			it( 'creates new - no attributes', () => {
+				extensionTest(
+					{
+						tag: 'p',
+					},
+					{
+						attributes: {
+							c: 'd'
+						}
+					},
+					'<p c="d"></p>'
+				);
+			} );
+
+			it( 'creates new - simple', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: 'b'
+						}
+					},
+					{
+						attributes: {
+							c: 'd'
+						}
+					},
+					'<p a="b" c="d"></p>'
+				);
+			} );
+
+			it( 'creates new - array', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: 'b'
+						}
+					},
+					{
+						attributes: {
+							c: [ 'd', 'e' ]
+						}
+					},
+					'<p a="b" c="d e"></p>'
+				);
+			} );
+
+			it( 'creates new - bindings #1', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: 'b'
+						}
+					},
+					{
+						attributes: {
+							c: bind.to( 'foo' )
+						}
+					},
+					'<p a="b" c="bar"></p>'
+				);
+
+				observable.foo = 'abc';
+
+				expect( el.outerHTML ).to.equal( '<p a="b" c="abc"></p>' );
+			} );
+
+			it( 'creates new - bindings #2', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: 'b'
+						}
+					},
+					{
+						attributes: {
+							c: [ 'd', bind.to( 'foo' ) ]
+						}
+					},
+					'<p a="b" c="d bar"></p>'
+				);
+
+				observable.foo = 'abc';
+
+				expect( el.outerHTML ).to.equal( '<p a="b" c="d abc"></p>' );
+			} );
+		} );
+
+		describe( 'text', () => {
+			it( 'extends existing - simple', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							'foo'
+						]
+					},
+					{
+						children: [
+							'bar'
+						]
+					},
+					'<p>foobar</p>'
+				);
+
+				expect( el.childNodes ).to.have.length( 1 );
+			} );
+
+			it( 'extends existing - complex #1', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{ text: 'foo' }
+						]
+					},
+					{
+						children: [
+							'bar'
+						]
+					},
+					'<p>foobar</p>'
+				);
+
+				expect( el.childNodes ).to.have.length( 1 );
+			} );
+
+			it( 'extends existing - complex #2', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{ text: 'foo' }
+						]
+					},
+					{
+						children: [
+							{ text: 'bar' }
+						]
+					},
+					'<p>foobar</p>'
+				);
+
+				expect( el.childNodes ).to.have.length( 1 );
+			} );
+
+			it( 'extends existing - bindings #1', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{ text: bind.to( 'foo' ) }
+						]
+					},
+					{
+						children: [
+							'abc'
+						]
+					},
+					'<p>bar abc</p>'
+				);
+
+				observable.foo = 'asd';
+
+				expect( el.outerHTML ).to.equal( '<p>asd abc</p>' );
+				expect( el.childNodes ).to.have.length( 1 );
+			} );
+
+			it( 'extends existing - bindings #2', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							'abc'
+						]
+					},
+					{
+						children: [
+							{ text: bind.to( 'foo' ) }
+						]
+					},
+					'<p>abc bar</p>'
+				);
+
+				observable.foo = 'asd';
+
+				expect( el.outerHTML ).to.equal( '<p>abc asd</p>' );
+				expect( el.childNodes ).to.have.length( 1 );
+			} );
+
+			it( 'extends existing - bindings #3', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{ text: bind.to( 'foo' ) },
+							'X'
+						]
+					},
+					{
+						children: [
+							{ text: bind.to( 'baz' ) },
+							'Y'
+						]
+					},
+					'<p>bar quxXY</p>'
+				);
+
+				observable.foo = 'A';
+				observable.baz = 'B';
+
+				expect( el.outerHTML ).to.equal( '<p>A BXY</p>' );
+				expect( el.childNodes ).to.have.length( 2 );
+			} );
+		} );
+
+		describe( 'children', () => {
+			it( 'should throw when the number of children does not correspond', () => {
+				expect( () => {
+					extensionTest(
+						{
+							tag: 'p',
+							children: [
+								'foo'
+							]
+						},
+						{
+							children: [
+								'foo',
+								'bar'
+							]
+						},
+						'it should fail'
+					);
+				} ).to.throw( CKEditorError, /ui-template-extend-children-mismatch/ );
+			} );
+
+			it( 'should throw when no children in target but extending one', () => {
+				expect( () => {
+					extensionTest(
+						{
+							tag: 'p',
+						},
+						{
+							children: [
+								{
+									tag: 'b'
+								}
+							]
+						},
+						'it should fail'
+					);
+				} ).to.throw( CKEditorError, /ui-template-extend-children-mismatch/ );
+			} );
+
+			it( 'should throw when the number of children does not correspond on some deeper level', () => {
+				expect( () => {
+					extensionTest(
+						{
+							tag: 'p',
+							children: [
+								{
+									tag: 'span',
+									attributes: {
+										class: 'A'
+									},
+									children: [
+										'A',
+										{
+											tag: 'span',
+											attributes: {
+												class: 'AA'
+											},
+											children: [
+												'AA'
+											]
+										}
+									]
+								}
+							]
+						},
+						{
+							children: [
+								{
+									attributes: {
+										class: 'B'
+									},
+									children: [
+										'B'
+									]
+								}
+							]
+						},
+						'it should fail'
+					);
+				} ).to.throw( CKEditorError, /ui-template-extend-children-mismatch/ );
+			} );
+
+			it( 'extends existing - simple', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{
+								tag: 'span',
+								attributes: {
+									class: 'foo'
+								}
+							}
+						]
+					},
+					{
+						children: [
+							{
+								tag: 'span',
+								attributes: {
+									class: 'bar'
+								}
+							}
+						]
+					},
+					'<p><span class="foo bar"></span></p>'
+				);
+			} );
+
+			it( 'extends existing - complex', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{
+								tag: 'span',
+								attributes: {
+									class: 'A'
+								},
+								children: [
+									'A',
+									{
+										tag: 'span',
+										attributes: {
+											class: 'AA'
+										},
+										children: [
+											'AA'
+										]
+									}
+								]
+							}
+						]
+					},
+					{
+						children: [
+							{
+								tag: 'span',
+								attributes: {
+									class: 'B'
+								},
+								children: [
+									'B',
+									{
+										tag: 'span',
+										attributes: {
+											class: 'BB'
+										},
+										children: [
+											'BB'
+										]
+									}
+								]
+							}
+						]
+					},
+					'<p><span class="A B">AB<span class="AA BB">AABB</span></span></p>'
+				);
+			} );
+
+			it( 'allows extending a particular child', () => {
+				const template = new Template( {
+					tag: 'p',
+					children: [
+						{
+							tag: 'span',
+							attributes: {
+								class: 'foo'
+							}
+						}
+					]
+				} );
+
+				Template.extend( template.definition.children[ 0 ], {
+					attributes: {
+						class: 'bar'
+					}
+				} );
+
+				expect( template.render().outerHTML ).to.equal( '<p><span class="foo bar"></span></p>' );
+			} );
+
+			it( 'allows extending a particular child – recursively', () => {
+				const template = new Template( {
+					tag: 'p',
+					children: [
+						{
+							tag: 'span',
+							attributes: {
+								class: 'A'
+							},
+							children: [
+								'A',
+								{
+									tag: 'span',
+									attributes: {
+										class: 'AA'
+									},
+									children: [
+										'AA'
+									]
+								}
+							]
+						}
+					]
+				} );
+
+				Template.extend( template.definition.children[ 0 ], {
+					attributes: {
+						class: 'B',
+					},
+					children: [
+						'B',
+						{
+							attributes: {
+								class: 'BB'
+							}
+						}
+					]
+				} );
+
+				expect( template.render().outerHTML ).to.equal( '<p><span class="A B">AB<span class="AA BB">AA</span></span></p>' );
+			} );
+		} );
+
+		describe( 'listeners', () => {
+			it( 'extends existing', () => {
+				const spy1 = testUtils.sinon.spy();
+				const spy2 = testUtils.sinon.spy();
+				const spy3 = testUtils.sinon.spy();
+				const spy4 = testUtils.sinon.spy();
+				const spy5 = testUtils.sinon.spy();
+
+				observable.on( 'A', spy1 );
+				observable.on( 'C', spy2 );
+
+				observable.on( 'B', spy3 );
+				observable.on( 'D', spy4 );
+
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{
+								tag: 'span'
+							}
+						],
+						on: {
+							click: bind.to( 'A' ),
+							'click@span': [
+								bind.to( 'B' ),
+								bind.to( spy5 )
+							]
+						}
+					},
+					{
+						on: {
+							click: bind.to( 'C' ),
+							'click@span': bind.to( 'D' )
+						}
+					},
+					'<p><span></span></p>'
+				);
+
+				dispatchEvent( el, 'click' );
+
+				expect( spy1.calledOnce ).to.be.true;
+				expect( spy2.calledOnce ).to.be.true;
+				expect( spy3.called ).to.be.false;
+				expect( spy4.called ).to.be.false;
+				expect( spy5.called ).to.be.false;
+
+				dispatchEvent( el.firstChild, 'click' );
+
+				expect( spy1.calledTwice ).to.be.true;
+				expect( spy2.calledTwice ).to.be.true;
+				expect( spy3.calledOnce ).to.be.true;
+				expect( spy4.calledOnce ).to.be.true;
+				expect( spy5.calledOnce ).to.be.true;
+			} );
+
+			it( 'creates new', () => {
+				const spy1 = testUtils.sinon.spy();
+				const spy2 = testUtils.sinon.spy();
+				const spy3 = testUtils.sinon.spy();
+
+				observable.on( 'A', spy1 );
+				observable.on( 'B', spy2 );
+
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{
+								tag: 'span'
+							}
+						],
+					},
+					{
+						on: {
+							click: bind.to( 'A' ),
+							'click@span': [
+								bind.to( 'B' ),
+								bind.to( spy3 )
+							]
+						}
+					},
+					'<p><span></span></p>'
+				);
+
+				dispatchEvent( el, 'click' );
+
+				expect( spy1.calledOnce ).to.be.true;
+				expect( spy2.called ).to.be.false;
+				expect( spy3.called ).to.be.false;
+
+				dispatchEvent( el.firstChild, 'click' );
+
+				expect( spy1.calledTwice ).to.be.true;
+				expect( spy2.calledOnce ).to.be.true;
+				expect( spy3.calledOnce ).to.be.true;
+			} );
+		} );
+	} );
+} );
+
+function setElement( template ) {
+	el = new Template( template ).render();
+	document.body.appendChild( el );
+}
+
+function extensionTest( base, extension, expectedHtml ) {
+	const template = new Template( base );
+
+	Template.extend( template, extension );
+
+	const el = template.render();
+
+	document.body.appendChild( el );
+
+	expect( el.outerHTML ).to.equal( expectedHtml );
+
+	return el;
+}
+
+function dispatchEvent( el, domEvtName ) {
+	if ( !el.parentNode ) {
+		throw new Error( 'To dispatch an event, element must be in DOM. Otherwise #target is null.' );
+	}
+
+	el.dispatchEvent( new Event( domEvtName, {
+		bubbles: true
+	} ) );
+}

Разница между файлами не показана из-за своего большого размера
+ 20 - 926
packages/ckeditor5-ui/tests/view.js


Некоторые файлы не были показаны из-за большого количества измененных файлов