8
0
Эх сурвалжийг харах

Allow easy binding syntax for existing elements.

Aleksander Nowodzinski 10 жил өмнө
parent
commit
f8fce71905

+ 67 - 43
packages/ckeditor5-engine/src/ui/template.js

@@ -34,10 +34,34 @@ export default class Template {
 	/**
 	 * Renders DOM Node using {@link #definition}.
 	 *
+	 * See: {@link #apply}.
+	 *
 	 * @returns {HTMLElement}
 	 */
 	render() {
-		return this._renderNode( this.definition, true );
+		return this._renderNode( this.definition, null, true );
+	}
+
+	/**
+	 * Applies template {@link #def} to existing DOM tree.
+	 *
+	 * **Note:** No new DOM nodes (elements, text nodes) will be created.
+	 *
+	 * See: {@link #render}, {@link View#applyTemplateToElement}.
+	 *
+	 * @param {Node} element Root element for template to apply.
+	 */
+	apply( node ) {
+		if ( !node ) {
+			/**
+			 * No DOM Node specified.
+			 *
+			 * @error ui-template-wrong-syntax
+			 */
+			throw new CKEditorError( 'ui-template-wrong-node' );
+		}
+
+		return this._renderNode( this.definition, node );
 	}
 
 	/**
@@ -45,10 +69,11 @@ export default class Template {
 	 *
 	 * @protected
 	 * @param {TemplateDefinition} def Definition of a Node.
+	 * @param {Node} applyNode If specified, template `def` will be applied to existing DOM Node.
 	 * @param {Boolean} intoFragment If set, children are rendered into DocumentFragment.
 	 * @returns {HTMLElement} A rendered Node.
 	 */
-	_renderNode( def, intoFragment ) {
+	_renderNode( def, applyNode, intoFragment ) {
 		const isText = def.text || typeof def == 'string';
 
 		// !XOR( def.tag, isText )
@@ -62,7 +87,7 @@ export default class Template {
 		}
 
 		return isText ?
-			this._renderText( def ) : this._renderElement( def, intoFragment );
+			this._renderText( def, applyNode ) : this._renderElement( def, applyNode, intoFragment );
 	}
 
 	/**
@@ -70,13 +95,13 @@ export default class Template {
 	 *
 	 * @protected
 	 * @param {TemplateDefinition} def Definition of an element.
+	 * @param {HTMLElement} applyElement If specified, template `def` will be applied to existing HTMLElement.
 	 * @param {Boolean} intoFragment If set, children are rendered into DocumentFragment.
 	 * @returns {HTMLElement} A rendered element.
 	 */
-	_renderElement( def, intoFragment ) {
-		const el = document.createElement( def.tag );
+	_renderElement( def, applyElement, intoFragment ) {
+		const el = applyElement || document.createElement( def.tag );
 
-		// Set attributes.
 		this._renderElementAttributes( def, el );
 
 		// Invoke children recursively.
@@ -87,11 +112,11 @@ export default class Template {
 
 			el.appendChild( docFragment );
 		} else {
-			this._renderElementChildren( def, el );
+			this._renderElementChildren( def, el, !!applyElement );
 		}
 
 		// Activate DOM bindings for event listeners.
-		this._activateElementListeners( def, el );
+		this._activateElementListenerAttachers( def, el );
 
 		return el;
 	}
@@ -101,28 +126,23 @@ 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.
 	 * @returns {Text} A rendered Text.
 	 */
-	_renderText( defOrText ) {
-		const el = document.createTextNode( '' );
-
-		// Case: { text: ... }
-		if ( defOrText.text ) {
-			// Case: { text: func }, like binding
-			if ( typeof defOrText.text == 'function' ) {
-				defOrText.text( el, getTextUpdater() );
-			}
-			// Case: { text: 'foo' }
-			else {
-				el.textContent = defOrText.text;
-			}
+	_renderText( defOrText, applyText ) {
+		const text = applyText || document.createTextNode( '' );
+
+		// Case: { text: func }, like binding
+		if ( typeof defOrText.text == 'function' ) {
+			defOrText.text( text, getTextUpdater() );
 		}
+		// Case: { text: 'foo' }
 		// Case: 'foo'
 		else {
-			el.textContent = defOrText;
+			text.textContent = defOrText.text || defOrText;
 		}
 
-		return el;
+		return text;
 	}
 
 	/**
@@ -162,11 +182,16 @@ export default class Template {
 	 * @protected
 	 * @param {TemplateDefinition} def Definition of an element.
 	 * @param {HTMLElement} el Element which is rendered.
+	 * @param {Boolean} isApply Traverse existing DOM structure only, don't modify DOM.
 	 */
-	_renderElementChildren( def, el ) {
+	_renderElementChildren( def, el, isApply ) {
 		if ( def.children ) {
-			def.children.map( childDef => {
-				el.appendChild( this._renderNode( childDef ) );
+			def.children.map( ( childDef, index ) => {
+				if ( isApply ) {
+					this._renderNode( childDef, el.childNodes[ index ] );
+				} else {
+					el.appendChild( this._renderNode( childDef ) );
+				}
 			} );
 		}
 	}
@@ -178,28 +203,27 @@ export default class Template {
 	 * @param {TemplateDefinition} def Definition of an element.
 	 * @param {HTMLElement} el Element which is rendered.
 	 */
-	_activateElementListeners( def, el ) {
-		if ( def.on ) {
-			let l, domEvtDef, name, selector;
+	_activateElementListenerAttachers( def, el ) {
+		if ( !def.on ) {
+			return;
+		}
 
-			for ( l in def.on ) {
-				domEvtDef = l.split( '@' );
+		const attachers = def.on._listenerAttachers;
 
-				if ( domEvtDef.length == 2 ) {
-					name = domEvtDef[ 0 ];
-					selector = domEvtDef[ 1 ];
-				} else {
-					name = l;
-					selector = null;
-				}
+		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;
 
-				if ( Array.isArray( def.on[ l ] ) ) {
-					def.on[ l ].map( i => i( el, name, selector ) );
+				if ( Array.isArray( attachers[ key ] ) ) {
+					attachers[ key ].forEach( i => i( el, evtName, evtSelector ) );
 				} else {
-					def.on[ l ]( el, name, selector );
+					attachers[ key ]( el, evtName, evtSelector );
 				}
-			}
-		}
+			} );
 	}
 }
 

+ 60 - 13
packages/ckeditor5-engine/src/ui/view.js

@@ -97,7 +97,7 @@ export default class View {
 		}
 
 		// Prepare pre–defined listeners.
-		this._prepareElementListeners( this.template );
+		this._createTemplateListenerAttachers( this.template );
 
 		this._template = new Template( this.template );
 
@@ -234,6 +234,38 @@ export default class View {
 		};
 	}
 
+	/**
+	 * 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 Template#apply}.
+	 *
+	 * @param {DOMElement} element DOM Element to initialize.
+	 * @param {TemplateDefinition} def Template definition to be applied.
+	 */
+	applyTemplateToElement( element, def ) {
+		this._createTemplateListenerAttachers( def );
+
+		new Template( def ).apply( element );
+	}
+
 	/**
 	 * Destroys the view instance. The process includes:
 	 *  1. Removal of child views from {@link #regions}.
@@ -292,7 +324,7 @@ export default class View {
 	 *
 	 * @protected
 	 * @param {String|Function} evtNameOrCallback Event name to be fired on View or callback to execute.
-	 * @returns {Function} A function to be executed in the context of an element.
+	 * @returns {Function} A listener attacher function to be executed in the context of an element.
 	 */
 	_getDOMListenerAttacher( evtNameOrCallback ) {
 		/**
@@ -310,11 +342,11 @@ export default class View {
 		 * a single, more efficient listener can be attached that uses **event delegation**:
 		 *
 		 *     children: [
-		 *     	   { tag: 'span' }
-		 *     	   { tag: 'span' }
+		 *         { tag: 'span' }
+		 *         { tag: 'span' }
 		 *     ],
 		 *     on: {
-		 *     	   'click@span': 'foo',
+		 *         'click@span': 'foo',
 		 *     }
 		 *
 		 * @param {HTMLElement} el Element, to which the native DOM Event listener is attached.
@@ -336,19 +368,26 @@ export default class View {
 	}
 
 	/**
-	 * Iterates over "on" property in {@link template} definition to recursively
+	 * 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 the element.
+	 * of an element, attaches native DOM listener to that element.
 	 *
 	 * @protected
 	 * @param {TemplateDefinition} def Template definition.
 	 */
-	_prepareElementListeners( def ) {
-		let on = def.on;
+	_createTemplateListenerAttachers( def ) {
+		const on = def.on;
 
-		if ( 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 ];
 
@@ -360,7 +399,7 @@ export default class View {
 				//        ...
 				//    }
 				if ( Array.isArray( evtNameOrCallback ) ) {
-					on[ domEvtName ] = on[ domEvtName ].map( this._getDOMListenerAttacher, this );
+					on._listenerAttachers[ domEvtName ] = on[ domEvtName ].map( this._getDOMListenerAttacher, this );
 				}
 				// Listeners allow definition with a string containing event name:
 				//
@@ -370,14 +409,22 @@ export default class View {
 				//       ...
 				//    }
 				else {
-					on[ domEvtName ] = this._getDOMListenerAttacher( evtNameOrCallback );
+					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.map( this._prepareElementListeners, this );
+			def.children.map( this._createTemplateListenerAttachers, this );
 		}
 	}
 }

+ 288 - 101
packages/ckeditor5-engine/tests/ui/template.js

@@ -14,6 +14,8 @@ import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
 testUtils.createSinonSandbox();
 
+let el, text;
+
 describe( 'Template', () => {
 	describe( 'constructor', () => {
 		it( 'accepts the definition', () => {
@@ -39,7 +41,7 @@ describe( 'Template', () => {
 			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
 		} );
 
-		it( 'creates a HTMLElement', () => {
+		it( 'creates a HTMLElement with attributes', () => {
 			let el = new Template( {
 				tag: 'p',
 				attributes: {
@@ -110,130 +112,315 @@ describe( 'Template', () => {
 			expect( el.outerHTML ).to.be.equal( '<p>abcd</p>' );
 		} );
 
-		describe( 'callback', () => {
-			it( 'works for attributes', () => {
-				let spy1 = testUtils.sinon.spy();
-				let spy2 = testUtils.sinon.spy();
+		it( 'activates listener attachers – root', () => {
+			let spy1 = testUtils.sinon.spy();
+			let spy2 = testUtils.sinon.spy();
+			let spy3 = testUtils.sinon.spy();
 
-				let el = new Template( {
-					tag: 'p',
-					attributes: {
-						'class': spy1
+			let 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', () => {
+			let spy = testUtils.sinon.spy();
+			let 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', () => {
+			let spy1 = testUtils.sinon.spy();
+			let spy2 = testUtils.sinon.spy();
+			let spy3 = testUtils.sinon.spy();
+			let spy4 = testUtils.sinon.spy();
+
+			let el = new Template( {
+				tag: 'p',
+				children: [
+					{
+						tag: 'span',
+						attributes: {
+							'id': 'x'
+						}
 					},
-					children: [
-						{
-							tag: 'span',
-							attributes: {
-								id: spy2
+					{
+						tag: 'span',
+						attributes: {
+							'class': 'y'
+						},
+						on: {
+							_listenerAttachers: {
+								'bar@p': spy2
 							}
 						}
-					]
-				} ).render();
+					},
+				],
+				on: {
+					_listenerAttachers: {
+						'foo@span': spy1,
+						'baz@.y': [ spy3, spy4 ]
+					}
+				}
+			} ).render();
 
-				sinon.assert.calledWithExactly( spy1, el, sinon.match.func );
-				sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.func );
+			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' );
+		} );
 
-				spy1.firstCall.args[ 1 ]( el, 'foo' );
-				spy2.firstCall.args[ 1 ]( el.firstChild, 'bar' );
+		it( 'activates model bindings – attributes', () => {
+			let spy1 = testUtils.sinon.spy();
+			let spy2 = testUtils.sinon.spy();
 
-				expect( el.outerHTML ).to.be.equal( '<p class="foo"><span id="bar"></span></p>' );
-			} );
+			let el = new Template( {
+				tag: 'p',
+				attributes: {
+					'class': spy1
+				},
+				children: [
+					{
+						tag: 'span',
+						attributes: {
+							id: spy2
+						}
+					}
+				]
+			} ).render();
+
+			sinon.assert.calledWithExactly( spy1, el, sinon.match.func );
+			sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.func );
 
-			it( 'works for "text" property', () => {
-				let spy1 = testUtils.sinon.spy();
-				let spy2 = testUtils.sinon.spy();
+			spy1.firstCall.args[ 1 ]( el, 'foo' );
+			spy2.firstCall.args[ 1 ]( el.firstChild, 'bar' );
 
-				let el = new Template( {
+			expect( el.outerHTML ).to.be.equal( '<p class="foo"><span id="bar"></span></p>' );
+		} );
+
+		it( 'activates model bindings – Text Node', () => {
+			let spy1 = testUtils.sinon.spy();
+			let spy2 = testUtils.sinon.spy();
+
+			let el = new Template( {
+				tag: 'p',
+				children: [
+					{
+						text: spy1
+					},
+					{
+						tag: 'span',
+						children: [
+							{
+								text: spy2
+							}
+						]
+					}
+				]
+			} ).render();
+
+			sinon.assert.calledWithExactly( spy1, el.firstChild, sinon.match.func );
+			sinon.assert.calledWithExactly( spy2, el.lastChild.firstChild, sinon.match.func );
+
+			spy2.firstCall.args[ 1 ]( el.lastChild.firstChild, 'bar' );
+			expect( el.outerHTML ).to.be.equal( '<p><span>bar</span></p>' );
+
+			spy1.firstCall.args[ 1 ]( el.firstChild, 'foo' );
+			expect( el.outerHTML ).to.be.equal( '<p>foo<span>bar</span></p>' );
+		} );
+	} );
+
+	describe( 'apply', () => {
+		beforeEach( () => {
+			el = document.createElement( 'div' );
+			text = document.createTextNode( '' );
+		} );
+
+		it( 'throws when wrong template definition', () => {
+			expect( () => {
+				new Template( {} ).apply( el );
+			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
+
+			expect( () => {
+				new Template( {
 					tag: 'p',
-					children: [
-						{
-							text: spy1
-						},
-						{
-							tag: 'span',
-							children: [
-								{
-									text: spy2
-								}
-							]
+					text: 'foo'
+				} ).apply( el );
+			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
+		} );
+
+		it( 'throws when no HTMLElement passed', () => {
+			expect( () => {
+				new Template( {
+					tag: 'p'
+				} ).apply();
+			} ).to.throw( CKEditorError, /ui-template-wrong-node/ );
+		} );
+
+		it( 'applies textContent to a Text Node', () => {
+			new Template( {
+				text: 'abc'
+			} ).apply( text );
+
+			expect( text.textContent ).to.be.equal( 'abc' );
+		} );
+
+		it( 'applies attributes to an HTMLElement', () => {
+			new Template( {
+				tag: 'div',
+				attributes: {
+					'class': [ 'a', 'b' ],
+					x: 'bar'
+				}
+			} ).apply( el );
+
+			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', () => {
+			new Template( {
+				tag: 'div',
+				children: [ 'foo' ]
+			} ).apply( el );
+
+			expect( el.outerHTML ).to.be.equal( '<div></div>' );
+		} );
+
+		it( 'applies doesn\'t apply new child to an HTMLElement – HTMLElement', () => {
+			new Template( {
+				tag: 'div',
+				children: [
+					{
+						tag: 'span'
+					}
+				]
+			} ).apply( el );
+
+			expect( el.outerHTML ).to.be.equal( '<div></div>' );
+		} );
+
+		it( 'applies new textContent to an existing Text Node of an HTMLElement', () => {
+			el.textContent = 'bar';
+
+			new Template( {
+				tag: 'div',
+				children: [ 'foo' ]
+			} ).apply( el );
+
+			expect( el.outerHTML ).to.be.equal( '<div>foo</div>' );
+		} );
+
+		it( 'applies attributes and TextContent to a DOM tree', () => {
+			el.textContent = 'abc';
+			el.appendChild( document.createElement( 'span' ) );
+
+			new Template( {
+				tag: 'div',
+				attributes: {
+					'class': 'parent'
+				},
+				children: [
+					'Children: ',
+					{
+						tag: 'span',
+						attributes: {
+							class: 'child'
 						}
-					]
-				} ).render();
+					}
+				]
+			} ).apply( el );
 
-				sinon.assert.calledWithExactly( spy1, el.firstChild, sinon.match.func );
-				sinon.assert.calledWithExactly( spy2, el.lastChild.firstChild, sinon.match.func );
+			expect( el.outerHTML ).to.be.equal( '<div class="parent">Children: <span class="child"></span></div>' );
+		} );
 
-				spy2.firstCall.args[ 1 ]( el.lastChild.firstChild, 'bar' );
-				expect( el.outerHTML ).to.be.equal( '<p><span>bar</span></p>' );
+		it( 'activates listener attachers – root', () => {
+			const spy = testUtils.sinon.spy();
 
-				spy1.firstCall.args[ 1 ]( el.firstChild, 'foo' );
-				expect( el.outerHTML ).to.be.equal( '<p>foo<span>bar</span></p>' );
-			} );
+			new Template( {
+				tag: 'div',
+				on: {
+					_listenerAttachers: {
+						click: spy
+					}
+				}
+			} ).apply( el );
 
-			it( 'works for "on" property', () => {
-				let spy1 = testUtils.sinon.spy();
-				let spy2 = testUtils.sinon.spy();
-				let spy3 = testUtils.sinon.spy();
-				let spy4 = testUtils.sinon.spy();
+			sinon.assert.calledWithExactly( spy, el, 'click', null );
+		} );
 
-				let el = new Template( {
-					tag: 'p',
-					children: [
-						{
-							tag: 'span',
-							on: {
-								bar: spy2
+		it( 'activates listener attachers – children', () => {
+			const spy = testUtils.sinon.spy();
+			el.appendChild( document.createElement( 'span' ) );
+
+			new Template( {
+				tag: 'div',
+				children: [
+					{
+						tag: 'span',
+						on: {
+							_listenerAttachers: {
+								click: spy
 							}
 						}
-					],
-					on: {
-						foo: spy1,
-						baz: [ spy3, spy4 ]
 					}
-				} ).render();
+				]
+			} ).apply( el );
 
-				sinon.assert.calledWithExactly( spy1, el, 'foo', null );
-				sinon.assert.calledWithExactly( spy2, el.firstChild, 'bar', null );
-				sinon.assert.calledWithExactly( spy3, el, 'baz', null );
-				sinon.assert.calledWithExactly( spy4, el, 'baz', null );
-			} );
+			sinon.assert.calledWithExactly( spy, el.firstChild, 'click', null );
+		} );
 
-			it( 'works for "on" property with selectors', () => {
-				let spy1 = testUtils.sinon.spy();
-				let spy2 = testUtils.sinon.spy();
-				let spy3 = testUtils.sinon.spy();
-				let spy4 = testUtils.sinon.spy();
+		it( 'activates model bindings – root', () => {
+			const spy = testUtils.sinon.spy();
 
-				let el = new Template( {
-					tag: 'p',
-					children: [
-						{
-							tag: 'span',
-							attributes: {
-								'id': 'x'
-							}
-						},
-						{
-							tag: 'span',
-							attributes: {
-								'class': 'y'
-							},
-							on: {
-								'bar@p': spy2
-							}
-						},
-					],
-					on: {
-						'foo@span': spy1,
-						'baz@.y': [ spy3, spy4 ]
+			new Template( {
+				tag: 'div',
+				attributes: {
+					class: spy
+				}
+			} ).apply( el );
+
+			sinon.assert.calledWithExactly( spy, el, sinon.match.func );
+		} );
+
+		it( 'activates model bindings – children', () => {
+			const spy = testUtils.sinon.spy();
+			el.appendChild( document.createElement( 'span' ) );
+
+			new Template( {
+				tag: 'div',
+				children: [
+					{
+						tag: 'span',
+						attributes: {
+							class: spy
+						}
 					}
-				} ).render();
+				]
+			} ).apply( el );
 
-				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' );
-			} );
+			sinon.assert.calledWithExactly( spy, el.firstChild, sinon.match.func );
 		} );
 	} );
 } );

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

@@ -628,6 +628,195 @@ describe( 'View', () => {
 			} ).to.not.throw();
 		} );
 	} );
+
+	describe( 'applyTemplateToElement', () => {
+		beforeEach( () => {
+			setTestViewClass();
+			setTestViewInstance( { a: 'foo', b: 42 } );
+		} );
+
+		it( 'should initialize attribute bindings', () => {
+			const el = document.createElement( 'div' );
+
+			view.applyTemplateToElement( el, {
+				tag: 'div',
+				attributes: {
+					class: view.bindToAttribute( 'b' ),
+					id: 'foo',
+					checked: ''
+				}
+			} );
+
+			expect( el.outerHTML ).to.be.equal( '<div class="42" id="foo" checked=""></div>' );
+
+			view.model.b = 64;
+
+			expect( el.outerHTML ).to.be.equal( '<div class="64" id="foo" checked=""></div>' );
+		} );
+
+		it( 'should initialize DOM listeners', () => {
+			const el = document.createElement( 'div' );
+			const spy = testUtils.sinon.spy();
+
+			view.applyTemplateToElement( el, {
+				tag: 'div',
+				on: {
+					click: spy
+				}
+			} );
+
+			document.body.appendChild( el );
+
+			dispatchEvent( el, 'click' );
+
+			sinon.assert.calledOnce( spy );
+
+			view.stopListening( el, 'click' );
+			dispatchEvent( el, 'click' );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should work for deep DOM structure', () => {
+			const el = document.createElement( 'div' );
+			const childA = document.createElement( 'a' );
+			const childB = document.createElement( 'b' );
+
+			childA.textContent = 'anchor';
+			childB.textContent = 'bold';
+
+			el.appendChild( childA );
+			el.appendChild( childB );
+
+			expect( el.outerHTML ).to.be.equal( '<div><a>anchor</a><b>bold</b></div>' );
+
+			const spy1 = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+			const spy3 = testUtils.sinon.spy();
+
+			view.applyTemplateToElement( el, {
+				tag: 'div',
+				children: [
+					{
+						tag: 'a',
+						on: {
+							keyup: spy2
+						},
+						attributes: {
+							class: view.bindToAttribute( 'b', ( el, b ) => 'applied-A-' + b ),
+							id: 'applied-A'
+						},
+						children: [ 'Text applied to childA.' ]
+					},
+					{
+						tag: 'b',
+						on: {
+							keydown: spy3
+						},
+						attributes: {
+							class: view.bindToAttribute( 'b', ( el, b ) => 'applied-B-' + b ),
+							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': spy1
+				},
+				attributes: {
+					id: view.bindToAttribute( 'a', ( el, a ) => a.toUpperCase() ),
+					class: view.bindToAttribute( 'b', ( el, b ) => 'applied-parent-' + b )
+				}
+			} );
+
+			expect( el.outerHTML ).to.be.equal( '<div id="FOO" class="applied-parent-42">' +
+				'<a class="applied-A-42" id="applied-A">Text applied to childA.</a>' +
+				'<b class="applied-B-42" id="applied-B">Text applied to childB.</b>' +
+			'</div>' );
+
+			view.model.b = 16;
+
+			expect( el.outerHTML ).to.be.equal( '<div id="FOO" class="applied-parent-16">' +
+				'<a class="applied-A-16" id="applied-A">Text applied to childA.</a>' +
+				'<b class="applied-B-16" 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( 're–uses a template definition object without redundant re–definition of "on" listener attachers', () => {
+			const el1 = document.createElement( 'div' );
+			const el2 = document.createElement( 'div' );
+			const spy = testUtils.sinon.spy();
+			const def = {
+				tag: 'div',
+				on: {
+					click: spy
+				}
+			};
+
+			view.applyTemplateToElement( el1, def );
+			const attacherFn = def.on.click;
+			view.applyTemplateToElement( el2, def );
+
+			// Attacher function didn't change because it's still the same View instance.
+			expect( attacherFn ).to.be.equal( def.on.click );
+			expect( def.on._listenerView ).to.be.equal( view );
+
+			document.body.appendChild( el1 );
+			document.body.appendChild( el2 );
+
+			dispatchEvent( el1, 'click' );
+			sinon.assert.calledOnce( spy );
+
+			dispatchEvent( el2, 'click' );
+			sinon.assert.calledTwice( spy );
+		} );
+
+		it( 'shares a template definition between View instances', () => {
+			const el = document.createElement( 'div' );
+			const spy = testUtils.sinon.spy();
+			const view1 = new View();
+			const view2 = new View();
+			const def = {
+				tag: 'div',
+				on: {
+					click: spy
+				}
+			};
+
+			document.body.appendChild( el );
+
+			view1.applyTemplateToElement( el, def );
+			expect( def.on._listenerView ).to.be.equal( view1 );
+
+			dispatchEvent( el, 'click' );
+			sinon.assert.calledOnce( spy );
+
+			// Use another view1 to see if attachers are re–defined.
+			view2.applyTemplateToElement( el, def );
+			expect( def.on._listenerView ).to.be.equal( view2 );
+
+			dispatchEvent( el, 'click' );
+			// Spy was called for view1 (1st dispatch), then for view1 and view2 (2nd dispatch).
+			sinon.assert.calledThrice( spy );
+		} );
+	} );
 } );
 
 function createViewInstanceWithTemplate() {