Explorar o código

Moved DOM listener logic from View to Template.

Aleksander Nowodzinski %!s(int64=9) %!d(string=hai) anos
pai
achega
7bd33be849
Modificáronse 2 ficheiros con 490 adicións e 282 borrados
  1. 129 94
      packages/ckeditor5-ui/src/template.js
  2. 361 188
      packages/ckeditor5-ui/tests/template.js

+ 129 - 94
packages/ckeditor5-ui/src/template.js

@@ -44,7 +44,7 @@ export default class Template {
 	 * @returns {HTMLElement}
 	 */
 	render() {
-		return this._renderNode( this.definition, null, true );
+		return this._renderNode( this.definition, undefined, true );
 	}
 
 	/**
@@ -70,10 +70,6 @@ export default class Template {
 		return this._renderNode( this.definition, node );
 	}
 
-	destroy() {
-		this.stopListening();
-	}
-
 	/**
 	 * Renders a DOM Node from definition.
 	 *
@@ -84,16 +80,12 @@ export default class Template {
 	 * @returns {HTMLElement} A rendered Node.
 	 */
 	_renderNode( def, applyNode, intoFragment ) {
-		const isText = def.text || typeof def == 'string';
-		let isInvalid;
+		normalize( def );
 
-		if ( applyNode ) {
-			// When applying, a definition cannot have "tag" and "text" at the same time.
-			isInvalid = def.tag && isText;
-		} else {
-			// When rendering, a definition must have either "tag" or "text": XOR( def.tag, isText ).
-			isInvalid = def.tag ? isText : !isText;
-		}
+		// When applying, a definition cannot have "tag" and "text" at the same time.
+		// When rendering, a definition must have either "tag" or "text": XOR( def.tag, def.text ).
+		const isInvalid = applyNode ?
+			( def.tag && def.text ) : ( def.tag ? def.text : !def.text );
 
 		if ( isInvalid ) {
 			/**
@@ -105,7 +97,7 @@ export default class Template {
 			throw new CKEditorError( 'ui-template-wrong-syntax' );
 		}
 
-		return isText ?
+		return def.text ?
 			this._renderText( def, applyNode ) : this._renderElement( def, applyNode, intoFragment );
 	}
 
@@ -119,7 +111,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 );
@@ -135,8 +127,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;
 	}
@@ -146,25 +138,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( valueSchemaOrText, applyText ) {
-		const textNode = applyText || document.createTextNode( '' );
-
-		// Check if there's a binder available for this Text Node.
-		if ( hasModelBinding( valueSchemaOrText.text ) ) {
-			// Activate binder if one. Cases:
-			//		{ text: Template.bind.to( ... ) }
-			//		{ text: [ 'foo', Template.bind.to( ... ), ... ] }
-			this._setupBinding( valueSchemaOrText.text, textNode, getTextNodeUpdater( textNode ) );
+	_renderText( valueSchemaOrText, textNode = document.createTextNode( '' ) ) {
+		// Check if there's a binder available for this Text Node. Cases:
+		//		{ text: [ Template.bind.to( ... ) ] }
+		//		{ text: [ 'foo', Template.bind.to( ... ), ... ] }
+		if ( isBound( 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 = valueSchemaOrText.text || valueSchemaOrText;
+			textNode.textContent = valueSchemaOrText.text;
 		}
 
 		return textNode;
@@ -177,42 +166,39 @@ 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;
+			attrNs = attrValue[ 0 ].ns || null;
 
 			// Activate binder if one. Cases:
+			// 		{ class: [ Template.bind.to( ... ) ] }
 			// 		{ class: [ 'bar', Template.bind.to( ... ), 'baz' ] }
-			// 		{ class: Template.bind.to( ... ) }
 			// 		{ class: { ns: 'abc', value: Template.bind.to( ... ) } }
-			if ( hasModelBinding( attrValue ) ) {
-				this._setupBinding( attrValue, el, getElementAttributeUpdater( el, attrName, attrNs ) );
+			if ( isBound( 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( ( 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 );
 			}
@@ -247,27 +233,26 @@ export default class Template {
 	 * @param {ui.TemplateDefinition} def Definition of an element.
 	 * @param {HTMLElement} el Element which is 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;
-
-				if ( Array.isArray( attachers[ key ] ) ) {
-					attachers[ key ].forEach( i => i( el, evtName, evtSelector ) );
-				} else {
-					attachers[ key ]( el, evtName, evtSelector );
-				}
+		for ( let key in def.on ) {
+			const [ domEvtName, domSelector ] = key.split( '@' );
+
+			def.on[ key ].forEach( schemaItem => {
+				schemaItem.emitter.listenTo( el, domEvtName, ( evt, domEvt ) => {
+					if ( !domSelector || domEvt.target.matches( domSelector ) ) {
+						if ( typeof schemaItem.eventNameOrFuncion == 'function' ) {
+							schemaItem.eventNameOrFuncion( domEvt );
+						} else {
+							schemaItem.observable.fire( schemaItem.eventNameOrFuncion, domEvt );
+						}
+					}
+				} );
 			} );
+		}
 	}
 
 	/**
@@ -282,15 +267,7 @@ export default class Template {
 	 * @param {Function} domUpdater A function provided by {@link Template} which updates corresponding
 	 * DOM attribute or `textContent`.
 	 */
-	_setupBinding( valueSchema, node, domUpdater ) {
-		// Normalize attributes with additional data like namespace:
-		// class: { ns: 'abc', value: [ ... ] }
-		if ( valueSchema.value ) {
-			valueSchema = valueSchema.value;
-		}
-
-		valueSchema = normalizeBinderValueSchema( valueSchema );
-
+	_bindToObservable( valueSchema, node, domUpdater ) {
 		// 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.
@@ -338,7 +315,7 @@ export default class Template {
 					value = value === true ? '' : value;
 				}
 			} else {
-				value = value.reduce( binderValueReducer, '' );
+				value = value.reduce( arrayValueReducer, '' );
 				shouldSet = value;
 			}
 
@@ -371,11 +348,11 @@ mix( Template, EmitterMixin );
  * @type ui.TemplateBinding
  */
 Template.bind = ( observable, emitter ) => {
-	const binderFunction = ( eventName ) => {
+	const binderFunction = ( eventNameOrFuncion ) => {
 		return {
 			type: bindDOMEvtSymbol,
 			observable, emitter,
-			eventName
+			eventNameOrFuncion
 		};
 	};
 
@@ -464,13 +441,13 @@ Template.bind = ( observable, emitter ) => {
 /*
  * 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#_setupBinding
+ * @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;
@@ -485,7 +462,7 @@ 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#_setupBinding
+ * @see ui.View#_bindToObservable
  *
  * @private
  * @param {Node} node DOM Node to be modified.
@@ -493,7 +470,7 @@ function getTextNodeUpdater( node ) {
  * @param {String} [ns] 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 );
@@ -508,21 +485,79 @@ function getElementAttributeUpdater( el, attrName, ns = null ) {
 /**
  * Normalizes given {@link ui.TemplateValueSchema} it's always in an Array–like format:
  *
- * 		{ attributeName/text: 'bar' } ->
- * 			{ attributeName/text: [ 'bar' ] }
+ * 		{ attr|text: 'bar' } ->
+ * 			{ attr|text: [ 'bar' ] }
+ *
+ * 		{ attr|text: { model: ..., modelAttributeName: ..., callback: ... } } ->
+ * 			{ attr|text: [ { model: ..., modelAttributeName: ..., callback: ... } ] }
+ *
+ * 		{ attr|text: [ 'bar', { model: ..., modelAttributeName: ... }, 'baz' ] }
  *
- * 		{ attributeName/text: { model: ..., modelAttributeName: ..., callback: ... } } ->
- * 			{ attributeName/text: [ { model: ..., modelAttributeName: ..., callback: ... } ] }
+ *		'foo@selector': 'bar' ->
+ * 			'foo@selector': [ 'bar' ],
  *
- * 		{ attributeName/text: [ 'bar', { model: ..., modelAttributeName: ... }, 'baz' ] }
+ *		'foo@selector': [ 'bar', () => { ... } ] ->
+ *			'foo@selector': [ 'bar', () => { ... } ],
  *
  * @ignore
  * @private
  * @param {ui.TemplateValueSchema} valueSchema
  * @returns {Array}
  */
-function normalizeBinderValueSchema( valueSchema ) {
-	return Array.isArray( valueSchema ) ? valueSchema : [ valueSchema ];
+function normalize( def ) {
+	if ( !def ) {
+		return;
+	}
+
+	if ( def.attributes ) {
+		normalizeAttributes( def.attributes );
+	}
+
+	if ( def.on ) {
+		normalizeListeners( def.on );
+	}
+
+	if ( def.children ) {
+		normalizeTextChildren( def );
+	}
+}
+
+function normalizeAttributes( attrs ) {
+	for ( let a in attrs ) {
+		if ( attrs[ a ].value ) {
+			attrs[ a ].value = [].concat( attrs[ a ].value );
+		}
+
+		arrayify( attrs, a );
+	}
+}
+
+function normalizeListeners( listeners ) {
+	for ( let l in listeners ) {
+		arrayify( listeners, l );
+	}
+}
+
+function normalizeTextChildren( def ) {
+	def.children = def.children.map( c => {
+		if ( typeof c == 'string' ) {
+			return {
+				text: [ c ]
+			};
+		} else {
+			if ( c.text && !Array.isArray( c.text ) ) {
+				c.text = [ c.text ];
+			}
+		}
+
+		return c;
+	} );
+}
+
+function arrayify( obj, key ) {
+	if ( !Array.isArray( obj[ key ] ) ) {
+		obj[ key ] = [ obj[ key ] ];
+	}
 }
 
 /**
@@ -535,7 +570,7 @@ function normalizeBinderValueSchema( valueSchema ) {
  * @param {String} cur
  * @returns {String}
  */
-function binderValueReducer( prev, cur ) {
+function arrayValueReducer( prev, cur ) {
 	return prev === '' ?
 			`${cur}`
 		:
@@ -551,19 +586,19 @@ function binderValueReducer( prev, cur ) {
  * @param {ui.TemplateValueSchema} valueSchema
  * @returns {Boolean}
  */
-function hasModelBinding( valueSchema ) {
+function isBound( valueSchema ) {
 	if ( !valueSchema ) {
 		return false;
 	}
 
 	// Normalize attributes with additional data like namespace:
-	// class: { ns: 'abc', value: [ ... ] }
+	// 		class: { ns: 'abc', value: [ ... ] }
 	if ( valueSchema.value ) {
 		valueSchema = valueSchema.value;
 	}
 
 	if ( Array.isArray( valueSchema ) ) {
-		return valueSchema.some( hasModelBinding );
+		return valueSchema.some( isBound );
 	} else if ( valueSchema.observable ) {
 		return true;
 	}

+ 361 - 188
packages/ckeditor5-ui/tests/template.js

@@ -13,6 +13,7 @@ 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';
 import extend from '/ckeditor5/utils/lib/lodash/extend.js';
 
 testUtils.createSinonSandbox();
@@ -44,6 +45,62 @@ describe( 'Template', () => {
 			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
 		} );
 
+		it( 'normalizes template definition', () => {
+			const bind = Template.bind( new Model( {} ), extend( {}, 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( 'b' ),
+					'b@span': bind( () => {} ),
+					'c@span': [
+						bind( 'c' ),
+						bind( () => {} )
+					]
+				}
+			} );
+
+			tpl.render();
+
+			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( 'creates a HTMLElement', () => {
 			const el = new Template( {
 				tag: 'p',
@@ -110,7 +167,7 @@ describe( 'Template', () => {
 			const el = new Template( {
 				tag: 'p',
 				attributes: {
-					'class': {
+					class: {
 						ns: 'foo',
 						value: [ 'a', 'b' ]
 					},
@@ -183,86 +240,6 @@ describe( 'Template', () => {
 			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 ]
-					}
-				}
-			} ).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' );
-		} );
-
 		it( 'activates model bindings – root', () => {
 			const observable = new Model( {
 				foo: 'bar'
@@ -417,42 +394,6 @@ 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();
-
-			new Template( {
-				tag: 'div',
-				on: {
-					_listenerAttachers: {
-						click: spy
-					}
-				}
-			} ).apply( el );
-
-			sinon.assert.calledWithExactly( spy, el, 'click', null );
-		} );
-
-		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
-							}
-						}
-					}
-				]
-			} ).apply( el );
-
-			sinon.assert.calledWithExactly( spy, el.firstChild, 'click', null );
-		} );
 	} );
 
 	describe( 'bind', () => {
@@ -469,6 +410,220 @@ describe( 'Template', () => {
 		} );
 
 		describe( 'event', () => {
+			let observable, domEmitter, bind;
+
+			beforeEach( () => {
+				observable = new Model( {
+					foo: 'bar',
+					baz: 'qux'
+				} );
+
+				domEmitter = extend( {}, DOMEmitterMixin );
+				bind = Template.bind( observable, domEmitter );
+			} );
+
+			it( 'accepts plain binding', () => {
+				const spy = testUtils.sinon.spy();
+
+				setElement( {
+					tag: 'p',
+					on: {
+						x: bind( '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( 'a' ),
+							bind( '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': 'y',
+							},
+							on: {
+								'test@p': bind( 'c' )
+							}
+						},
+						{
+							tag: 'div',
+							children: [
+								{
+									tag: 'span',
+									attributes: {
+										'class': 'y',
+									}
+								}
+							],
+						}
+					],
+					on: {
+						'test@.y': bind( 'a' ),
+						'test@div': bind( 'b' )
+					}
+				} );
+
+				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( spy1 ),
+						'y@span': [
+							bind( spy2 ),
+							bind( '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( '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( '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', () => {
@@ -497,7 +652,7 @@ describe( 'Template', () => {
 				} );
 
 				it( 'allows binding attribute to the observable – simple (HTMLElement attribute)', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': bind.to( 'foo' )
@@ -505,15 +660,15 @@ describe( 'Template', () => {
 						children: [ 'abc' ]
 					} );
 
-					expect( element.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="bar">abc</p>' );
 
 					observable.foo = 'baz';
-					expect( element.outerHTML ).to.equal( '<p class="baz">abc</p>' );
-					expect( element.attributes.getNamedItem( 'class' ).namespaceURI ).to.be.null;
+					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)', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						children: [
 							{
@@ -522,15 +677,15 @@ describe( 'Template', () => {
 						]
 					} );
 
-					expect( element.outerHTML ).to.equal( '<p>bar</p>' );
+					expect( el.outerHTML ).to.equal( '<p>bar</p>' );
 
 					observable.foo = 'baz';
-					expect( element.outerHTML ).to.equal( '<p>baz</p>' );
+					expect( el.outerHTML ).to.equal( '<p>baz</p>' );
 				} );
 
 				it( 'allows binding attribute to the observable – value processing', () => {
 					const callback = value => value > 0 ? 'positive' : 'negative';
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': bind.to( 'foo', callback )
@@ -543,10 +698,10 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = 3;
-					expect( element.outerHTML ).to.equal( '<p class="positive">positive</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="positive">positive</p>' );
 
 					observable.foo = -7;
-					expect( element.outerHTML ).to.equal( '<p class="negative">negative</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="negative">negative</p>' );
 				} );
 
 				it( 'allows binding attribute to the observable – value processing (use Node)', () => {
@@ -554,7 +709,7 @@ describe( 'Template', () => {
 						return ( !!node.tagName && value > 0 ) ? 'HTMLElement positive' : '';
 					};
 
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': bind.to( 'foo', callback )
@@ -567,14 +722,14 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = 3;
-					expect( element.outerHTML ).to.equal( '<p class="HTMLElement positive"></p>' );
+					expect( el.outerHTML ).to.equal( '<p class="HTMLElement positive"></p>' );
 
 					observable.foo = -7;
-					expect( element.outerHTML ).to.equal( '<p></p>' );
+					expect( el.outerHTML ).to.equal( '<p></p>' );
 				} );
 
 				it( 'allows binding attribute to the observable – custom callback', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': bind.to( 'foo', ( value, el ) => {
@@ -588,14 +743,14 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = 'moo';
-					expect( element.outerHTML ).to.equal( '<p class="undefined">moo</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="undefined">moo</p>' );
 
 					observable.foo = 'changed';
-					expect( element.outerHTML ).to.equal( '<p class="changed">changed</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="changed">changed</p>' );
 				} );
 
 				it( 'allows binding attribute to the observable – array of bindings (HTMLElement attribute)', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': [
@@ -611,15 +766,15 @@ describe( 'Template', () => {
 
 					observable.foo = 'a';
 					observable.baz = 'b';
-					expect( element.outerHTML ).to.equal( '<p class="ck-class a b foo-is-a ck-end">abc</p>' );
+					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( element.outerHTML ).to.equal( '<p class="ck-class c d foo-is-c ck-end">abc</p>' );
+					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)', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 						},
@@ -638,15 +793,15 @@ describe( 'Template', () => {
 
 					observable.foo = 'a';
 					observable.baz = 'b';
-					expect( element.outerHTML ).to.equal( '<p>ck-class a b foo-is-a ck-end</p>' );
+					expect( el.outerHTML ).to.equal( '<p>ck-class a b foo-is-a ck-end</p>' );
 
 					observable.foo = 'c';
 					observable.baz = 'd';
-					expect( element.outerHTML ).to.equal( '<p>ck-class c d foo-is-c ck-end</p>' );
+					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', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': bind.to( 'foo' )
@@ -655,43 +810,50 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = 'bar';
-					expect( element.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="bar">abc</p>' );
 
 					observable.foo = false;
-					expect( element.outerHTML ).to.equal( '<p class="false">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="false">abc</p>' );
 
 					observable.foo = null;
-					expect( element.outerHTML ).to.equal( '<p class="null">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="null">abc</p>' );
 
 					observable.foo = undefined;
-					expect( element.outerHTML ).to.equal( '<p class="undefined">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="undefined">abc</p>' );
 
 					observable.foo = 0;
-					expect( element.outerHTML ).to.equal( '<p class="0">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="0">abc</p>' );
 
 					observable.foo = '';
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 				} );
 
 				it( 'allows binding attribute to the observable – a custom namespace', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
-							'class': {
+							class: {
 								ns: 'foo',
 								value: bind.to( 'foo' )
+							},
+							custom: {
+								ns: 'foo',
+								value: [
+									bind.to( 'foo' ),
+									bind.to( 'baz' )
+								]
 							}
 						},
 						children: [ 'abc' ]
 					} );
 
 					observable.foo = 'bar';
-					expect( element.outerHTML ).to.equal( '<p class="bar">abc</p>' );
-					expect( element.attributes.getNamedItem( 'class' ).namespaceURI ).to.equal( 'foo' );
+					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( element.outerHTML ).to.equal( '<p class="baz">abc</p>' );
-					expect( element.attributes.getNamedItem( 'class' ).namespaceURI ).to.equal( 'foo' );
+					expect( el.outerHTML ).to.equal( '<p class="baz" custom="baz qux">abc</p>' );
+					expect( el.attributes.getNamedItem( 'class' ).namespaceURI ).to.equal( 'foo' );
 				} );
 			} );
 
@@ -709,7 +871,7 @@ describe( 'Template', () => {
 				} );
 
 				it( 'allows binding attribute to the observable – presence of an attribute (HTMLElement attribute)', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': bind.if( 'foo' )
@@ -718,19 +880,19 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = true;
-					expect( element.outerHTML ).to.equal( '<p class="">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="">abc</p>' );
 
 					observable.foo = false;
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 
 					observable.foo = 'bar';
-					expect( element.outerHTML ).to.equal( '<p class="">abc</p>' );
+					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)', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						children: [
 							{
@@ -740,17 +902,17 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = true;
-					expect( element.outerHTML ).to.equal( '<p></p>' );
+					expect( el.outerHTML ).to.equal( '<p></p>' );
 
 					observable.foo = false;
-					expect( element.outerHTML ).to.equal( '<p></p>' );
+					expect( el.outerHTML ).to.equal( '<p></p>' );
 
 					observable.foo = 'bar';
-					expect( element.outerHTML ).to.equal( '<p></p>' );
+					expect( el.outerHTML ).to.equal( '<p></p>' );
 				} );
 
 				it( 'allows binding attribute to the observable – value of an attribute (HTMLElement attribute)', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': bind.if( 'foo', 'bar' )
@@ -759,17 +921,17 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = 'bar';
-					expect( element.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="bar">abc</p>' );
 
 					observable.foo = false;
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 
 					observable.foo = 64;
-					expect( element.outerHTML ).to.equal( '<p class="bar">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="bar">abc</p>' );
 				} );
 
 				it( 'allows binding attribute to the observable – value of an attribute (Text Node)', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						children: [
 							{
@@ -779,17 +941,17 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = 'bar';
-					expect( element.outerHTML ).to.equal( '<p>bar</p>' );
+					expect( el.outerHTML ).to.equal( '<p>bar</p>' );
 
 					observable.foo = false;
-					expect( element.outerHTML ).to.equal( '<p></p>' );
+					expect( el.outerHTML ).to.equal( '<p></p>' );
 
 					observable.foo = 64;
-					expect( element.outerHTML ).to.equal( '<p>bar</p>' );
+					expect( el.outerHTML ).to.equal( '<p>bar</p>' );
 				} );
 
 				it( 'allows binding attribute to the observable – array of bindings (HTMLElement attribute)', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': [
@@ -803,14 +965,14 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = observable.bar = true;
-					expect( element.outerHTML ).to.equal( '<p class="ck-class foo-set ck-end">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="ck-class foo-set ck-end">abc</p>' );
 
 					observable.foo = observable.bar = false;
-					expect( element.outerHTML ).to.equal( '<p class="ck-class bar-not-set ck-end">abc</p>' );
+					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', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': bind.if( 'foo', 'there–is–no–foo', value => !value )
@@ -819,17 +981,17 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = 'bar';
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 
 					observable.foo = false;
-					expect( element.outerHTML ).to.equal( '<p class="there–is–no–foo">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="there–is–no–foo">abc</p>' );
 
 					observable.foo = 64;
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					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)', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': bind.if( 'foo', 'eqls-tag-name', ( value, el ) => el.tagName === value )
@@ -838,17 +1000,17 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = 'bar';
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 
 					observable.foo = 'P';
-					expect( element.outerHTML ).to.equal( '<p class="eqls-tag-name">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="eqls-tag-name">abc</p>' );
 
 					observable.foo = 64;
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 				} );
 
 				it( 'allows binding attribute to the observable – falsy values', () => {
-					const element = getElement( {
+					setElement( {
 						tag: 'p',
 						attributes: {
 							'class': bind.if( 'foo', 'foo-is-set' )
@@ -857,22 +1019,22 @@ describe( 'Template', () => {
 					} );
 
 					observable.foo = 'bar';
-					expect( element.outerHTML ).to.equal( '<p class="foo-is-set">abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p class="foo-is-set">abc</p>' );
 
 					observable.foo = false;
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 
 					observable.foo = null;
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 
 					observable.foo = undefined;
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 
 					observable.foo = '';
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 
 					observable.foo = 0;
-					expect( element.outerHTML ).to.equal( '<p>abc</p>' );
+					expect( el.outerHTML ).to.equal( '<p>abc</p>' );
 				} );
 			} );
 
@@ -920,6 +1082,17 @@ describe( 'Template', () => {
 	} );
 } );
 
-function getElement( template ) {
-	return new Template( template ).render();
+function setElement( template ) {
+	el = new Template( template ).render();
+	document.body.appendChild( 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
+	} ) );
 }