Explorar el Código

Improved View attribute binder. Implemented bind.to and bind.if interface.

Aleksander Nowodzinski hace 9 años
padre
commit
c8a5eeded5

+ 94 - 31
packages/ckeditor5-engine/src/ui/template.js

@@ -139,19 +139,27 @@ export default class Template {
 	 * @returns {Text} A rendered Text.
 	 */
 	_renderText( defOrText, applyText ) {
-		const text = applyText || document.createTextNode( '' );
+		const textNode = applyText || document.createTextNode( '' );
 
-		// Case: { text: func }, like binding
-		if ( typeof defOrText.text == 'function' ) {
-			defOrText.text( text, getTextUpdater() );
+		// 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 ) );
 		}
-		// Case: { text: 'foo' }
-		// Case: 'foo'
+
+		// Simply set text. Cases:
+		// 		{ text: [ 'all', 'are', 'static' ] }
+		// 		{ text: 'foo' }
+		// 		'foo'
 		else {
-			text.textContent = defOrText.text || defOrText;
+			textNode.textContent = defOrText.text || defOrText;
 		}
 
-		return text;
+		return textNode;
 	}
 
 	/**
@@ -162,24 +170,39 @@ export default class Template {
 	 * @param {HTMLElement} el Element which is rendered.
 	 */
 	_renderElementAttributes( def, el ) {
-		let attr, value;
+		const attributes = def.attributes;
+		const binders = def._modelBinders && def._modelBinders.attributes;
+		let binder, attrName, attrValue;
+
+		if ( !attributes ) {
+			return;
+		}
 
-		for ( attr in def.attributes ) {
-			value = def.attributes[ attr ];
+		for ( attrName in attributes ) {
+			// Check if there's a binder available for this attribute.
+			binder = binders && binders[ attrName ];
 
-			// Attribute bound directly to the model.
-			if ( typeof value == 'function' ) {
-				value( el, getAttributeUpdater( attr ) );
+			// Activate binder if one. Cases:
+			// 		{ class: [ 'bar', bind.to( ... ), 'baz' ] }
+			// 		{ class: bind.to( ... ) }
+			if ( binder ) {
+				binder( el, getElementAttributeUpdater( el, attrName ) );
 			}
 
-			// Explicit attribute definition (string).
+			// Otherwise simply set the attribute.
+			// 		{ class: [ 'all', 'are', 'static' ] }
+			// 		{ class: 'foo' }
 			else {
-				// Attribute can be an array, i.e. classes.
-				if ( Array.isArray( value ) ) {
-					value = value.join( ' ' );
+				attrValue = attributes[ attrName ];
+
+				// Attribute can be an array. Merge array elements:
+				if ( Array.isArray( attrValue ) ) {
+					attrValue = attrValue.reduce( function binderValueReducer( prev, cur ) {
+						return prev === '' ? `${cur}` : `${prev} ${cur}`;
+					} );
 				}
 
-				el.setAttribute( attr, value );
+				el.setAttribute( attrName, attrValue );
 			}
 		}
 	}
@@ -243,8 +266,16 @@ export default class Template {
  * @private
  * @param {Function}
  */
-function getTextUpdater() {
-	return ( el, value ) => el.textContent = value;
+function getTextNodeUpdater( node ) {
+	return {
+		set( value ) {
+			node.textContent = value;
+		},
+
+		remove() {
+			node.textContent = '';
+		}
+	};
 }
 
 /**
@@ -255,12 +286,21 @@ function getTextUpdater() {
  * @param {String} attr A name of the attribute to be updated.
  * @param {Function}
  */
-function getAttributeUpdater( attr ) {
-	return ( el, value ) => el.setAttribute( attr, value );
+function getElementAttributeUpdater( el, attrName ) {
+	return {
+		set( value ) {
+			el.setAttribute( attrName, value );
+		},
+
+		remove() {
+			el.removeAttribute( attrName );
+		}
+	};
 }
 
 /**
  * Definition of {@link Template}.
+ * See: {@link TemplateValueSchema}.
  *
  *		{
  *			tag: 'p',
@@ -272,22 +312,22 @@ function getAttributeUpdater( attr ) {
  *					...
  *				},
  *				{
- *					text: 'abc'
+ *					text: 'static–text'
  *				},
- *				'def',
+ *				'also-static–text',
  *				...
  *			],
  *			attributes: {
- *				'class': [ 'a', 'b' ],
- *				id: 'c',
+ *				'class': [ 'class-a', 'class-b' ],
+ *				id: 'element-id',
  *				style: callback,
  *				...
  *			},
  *			on: {
- *				event1: 'a'
- *				event2: [ 'b', 'c', callback ],
- *				'event3@selector': 'd',
- *				'event4@selector': [ 'e', 'f', callback ],
+ *				'click': 'clicked'
+ *				'mouseup': [ 'view-event-a', 'view-event-b', callback ],
+ *				'keyup@selector': 'view-event',
+ *				'focus@selector': [ 'view-event-a', 'view-event-b', callback ],
  *				...
  *			}
  *		}
@@ -299,4 +339,27 @@ function getAttributeUpdater( attr ) {
  * @property {Object} [attributes]
  * @property {String} [text]
  * @property {Object} [on]
+ * @property {Object} _modelBinders
+ */
+
+/**
+ * Describes a value of HTMLElement attribute or `textContent`.
+ * See: {@link TemplateDefinition}.
+ *
+ *		{
+ *			tag: 'p',
+ *			attributes: {
+ *				// Plain String schema.
+ *				class: 'class-foo'
+ *
+ *				// Object schema, a Model binding.
+ *				class: { model: m, attribute: 'foo', callback... }
+ *
+ *				// Array schema, combines the above.
+ *				class: [ 'foo', { model: m, attribute: 'bar' }, 'baz' ]
+ *			}
+ *		}
+ *
+ * @typedef TemplateValueSchema
+ * @type {Object|String|Array}
  */

+ 292 - 38
packages/ckeditor5-engine/src/ui/view.js

@@ -11,6 +11,10 @@ import Template from './template.js';
 import CKEditorError from '../ckeditorerror.js';
 import DOMEmitterMixin from './domemittermixin.js';
 import utils from '../utils.js';
+import isPlainObject from '../lib/lodash/isPlainObject.js';
+
+const bindToSymbol = Symbol( 'bind-to' );
+const bindIfSymbol = Symbol( 'bind-if' );
 
 /**
  * Basic View class.
@@ -97,7 +101,10 @@ export default class View {
 		}
 
 		// Prepare pre–defined listeners.
-		this._createTemplateListenerAttachers( this.template );
+		this._extendTemplateWithListenerAttachers( this.template );
+
+		// Prepare pre–defined attribute bindings.
+		this._extendTemplateWithModelBinders( this.template );
 
 		this._template = new Template( this.template );
 
@@ -191,47 +198,89 @@ export default class View {
 	}
 
 	/**
-	 * Binds an `attribute` of View's model so the DOM of the View is updated when the `attribute`
-	 * changes. It returns a function which, once called in the context of a DOM element,
-	 * attaches a listener to the model which, in turn, brings changes to DOM.
+	 * 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}.
 	 *
-	 * @param {String} attribute Attribute name in the model to be observed.
-	 * @param {Function} [callback] Callback function executed on attribute change in model.
-	 * If not specified, a default DOM `domUpdater` supplied by the template is used.
+	 * @property attributeBinder
 	 */
-	bindToAttribute( attribute, callback ) {
-		/**
-		 * Attaches a listener to View's model, which updates DOM when the model's attribute
-		 * changes. DOM is either updated by the `domUpdater` function supplied by the template
-		 * (like attribute changer or `innerHTML` setter) or custom `callback` passed to {@link #bind}.
-		 *
-		 * This function is called by {@link Template#render}.
-		 *
-		 * @param {HTMLElement} el DOM element to be updated when `attribute` in model changes.
-		 * @param {Function} domUpdater A function provided by the template which updates corresponding
-		 * DOM.
-		 */
-		return ( el, domUpdater ) => {
-			let onModelChange;
+	get attributeBinder() {
+		if ( this._attributeBinder ) {
+			return this._attributeBinder;
+		}
 
-			if ( callback ) {
-				onModelChange = ( evt, value ) => {
-					let processedValue = callback( el, value );
+		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', ( node, value ) => 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 {ViewModelBinding}
+			 */
+			to( attribute, callback ) {
+				return {
+					type: bindToSymbol,
+					model: model,
+					attribute,
+					callback
+				};
+			},
 
-					if ( typeof processedValue != 'undefined' ) {
-						domUpdater( el, processedValue );
-					}
+			/**
+			 * 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', ( node, value ) => !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 {ViewModelBinding}
+			 */
+			if( attribute, valueIfTrue, callback ) {
+				return {
+					type: bindIfSymbol,
+					model: model,
+					attribute,
+					valueIfTrue,
+					callback
 				};
-			} else {
-				onModelChange = ( evt, value ) => domUpdater( el, value );
 			}
-
-			// Execute callback when the attribute changes.
-			this.listenTo( this.model, 'change:' + attribute, onModelChange );
-
-			// Set the initial state of the view.
-			onModelChange( null, this.model[ attribute ] );
 		};
+
+		return ( this._attributeBinder = binder );
 	}
 
 	/**
@@ -261,7 +310,11 @@ export default class View {
 	 * @param {TemplateDefinition} def Template definition to be applied.
 	 */
 	applyTemplateToElement( element, def ) {
-		this._createTemplateListenerAttachers( def );
+		// Prepare pre–defined listeners.
+		this._extendTemplateWithListenerAttachers( def );
+
+		// Prepare pre–defined attribute bindings.
+		this._extendTemplateWithModelBinders( def );
 
 		new Template( def ).apply( element );
 	}
@@ -368,6 +421,145 @@ export default class View {
 		};
 	}
 
+	/**
+	 * For given {@link TemplateValueSchema} found by (@link _extendTemplateWithModelBinders} containing
+	 * {@link 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 TemplateValueSchema} can be for HTMLElement attributes or Text Node `textContent`.
+	 *
+	 * @protected
+	 * @param {TemplateValueSchema}
+	 * @return {Function}
+	 */
+	_getModelBinder( valueSchema ) {
+		valueSchema = normalizeBinderValueSchema( valueSchema );
+
+		/**
+		 * Assembles the value using {@link TemplateValueSchema} and stores it in a form of
+		 * an Array. Each entry of an Array corresponds to one of {@link TemplateValueSchema}
+		 * items.
+		 *
+		 * @private
+		 * @param {HTMLElement} el
+		 * @return {Array}
+		 */
+		const getBoundValue = ( el ) => {
+			let model, modelValue;
+
+			return valueSchema.map( schemaItem => {
+				model = schemaItem.model;
+
+				if ( model ) {
+					modelValue = model[ schemaItem.attribute ];
+
+					if ( schemaItem.callback ) {
+						modelValue = schemaItem.callback( el, modelValue );
+					}
+
+					return modelValue;
+				} else {
+					return schemaItem;
+				}
+			} );
+		};
+
+		/**
+		 * Attaches a listener to {@link View#model}, which updates DOM with a value constructed from
+		 * {@link TemplateValueSchema} when {@link View#model} attribute value changes.
+		 *
+		 * This function is called by {@link Template#render} or {@link Template#apply}.
+		 *
+		 * @param {HTMLElement} el DOM element 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 ( el, 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( el );
+
+				if ( isPlainBindIf ) {
+					value = value[ 0 ];
+				} else {
+					value = value.reduce( binderValueReducer, '' );
+				}
+
+				const isSet = isPlainBindIf ?
+					( typeof value == 'number' || !!value ) : value;
+
+				const valueToSet = isPlainBindIf ?
+					( valueSchema[ 0 ].valueIfTrue || '' ) : value;
+
+				if ( isSet ) {
+					domUpdater.set( valueToSet );
+				} 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 ViewModelBinding} created by {@link #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 {TemplateDefinition}
+	 */
+	_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
@@ -376,7 +568,7 @@ export default class View {
 	 * @protected
 	 * @param {TemplateDefinition} def Template definition.
 	 */
-	_createTemplateListenerAttachers( def ) {
+	_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.
@@ -425,7 +617,7 @@ export default class View {
 
 		// Repeat recursively for the children.
 		if ( def.children ) {
-			def.children.forEach( this._createTemplateListenerAttachers, this );
+			def.children.forEach( this._extendTemplateWithListenerAttachers, this );
 		}
 	}
 }
@@ -444,3 +636,65 @@ const validSelectorTypes = new Set( [ 'string', 'boolean', 'function' ] );
 function isValidRegionSelector( selector ) {
 	return validSelectorTypes.has( typeof selector ) && selector !== false;
 }
+
+/**
+ * Normalizes given {@link 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' ] }
+ *
+ * @private
+ * @param {TemplateValueSchema} valueSchema
+ * @returns {Array}
+ */
+function normalizeBinderValueSchema( valueSchema ) {
+	return Array.isArray( valueSchema ) ? valueSchema : [ valueSchema ];
+}
+
+/**
+ * Checks whether given {@link TemplateValueSchema} contains a
+ * {@link ViewModelBinding}.
+ *
+ * @private
+ * @param {TemplateValueSchema} valueSchema
+ * @returns {Boolean}
+ */
+function hasModelBinding( valueSchema ) {
+	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.
+ *
+ * @private
+ * @param {String} prev
+ * @param {String} cur
+ * @returns {String}
+ */
+function binderValueReducer( prev, cur ) {
+	return prev === '' ? `${cur}` : `${prev} ${cur}`;
+}
+
+/**
+ * Describes Model binding created by {@link View#attributeBinder}.
+ *
+ * @typedef ViewModelBinding
+ * @type Object
+ * @property {Symbol} type
+ * @property {Model} model
+ * @property {String} attribute
+ * @property {String} [valueIfTrue]
+ * @property {Funcion} [callback]
+ */

+ 77 - 26
packages/ckeditor5-engine/tests/ui/template.js

@@ -41,7 +41,17 @@ describe( 'Template', () => {
 			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
 		} );
 
-		it( 'creates a HTMLElement with attributes', () => {
+		it( 'creates a HTMLElement', () => {
+			const el = new Template( {
+				tag: 'p',
+			} ).render();
+
+			expect( el ).to.be.instanceof( HTMLElement );
+			expect( el.parentNode ).to.be.null;
+			expect( el.outerHTML ).to.be.equal( '<p></p>' );
+		} );
+
+		it( 'renders HTMLElement attributes', () => {
 			const el = new Template( {
 				tag: 'p',
 				attributes: {
@@ -56,7 +66,33 @@ describe( 'Template', () => {
 			expect( el.outerHTML ).to.be.equal( '<p class="a b" x="bar">foo</p>' );
 		} );
 
-		it( 'creates HTMLElement\'s children', () => {
+		it( 'renders HTMLElement attributes – empty', () => {
+			const el = new Template( {
+				tag: 'p',
+				attributes: {
+					class: '',
+					x: [ '', '' ]
+				},
+				children: [ 'foo' ]
+			} ).render();
+
+			expect( el.outerHTML ).to.be.equal( '<p class="" x="">foo</p>' );
+		} );
+
+		it( 'renders HTMLElement attributes – falsy values', () => {
+			const el = new Template( {
+				tag: 'p',
+				attributes: {
+					class: false,
+					x: [ '', null ]
+				},
+				children: [ 'foo' ]
+			} ).render();
+
+			expect( el.outerHTML ).to.be.equal( '<p class="false" x="null">foo</p>' );
+		} );
+
+		it( 'creates HTMLElement children', () => {
 			const el = new Template( {
 				tag: 'p',
 				attributes: {
@@ -199,25 +235,30 @@ describe( 'Template', () => {
 			const el = new Template( {
 				tag: 'p',
 				attributes: {
-					'class': spy1
+					'class': {}
 				},
 				children: [
 					{
 						tag: 'span',
 						attributes: {
-							id: spy2
+							id: {}
+						},
+						_modelBinders: {
+							attributes: {
+								id: spy2
+							}
 						}
 					}
-				]
+				],
+				_modelBinders: {
+					attributes: {
+						class: spy1
+					}
+				}
 			} ).render();
 
-			sinon.assert.calledWithExactly( spy1, el, sinon.match.func );
-			sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.func );
-
-			spy1.firstCall.args[ 1 ]( el, 'foo' );
-			spy2.firstCall.args[ 1 ]( el.firstChild, 'bar' );
-
-			expect( el.outerHTML ).to.be.equal( '<p class="foo"><span id="bar"></span></p>' );
+			sinon.assert.calledWithExactly( spy1, el, sinon.match.object );
+			sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.object );
 		} );
 
 		it( 'activates model bindings – Text Node', () => {
@@ -228,27 +269,27 @@ describe( 'Template', () => {
 				tag: 'p',
 				children: [
 					{
-						text: spy1
+						text: {},
+						_modelBinders: {
+							text: spy1
+						}
 					},
 					{
 						tag: 'span',
 						children: [
 							{
-								text: spy2
+								text: {},
+								_modelBinders: {
+									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>' );
+			sinon.assert.calledWithExactly( spy1, el.firstChild, sinon.match.object );
+			sinon.assert.calledWithExactly( spy2, el.lastChild.firstChild, sinon.match.object );
 		} );
 	} );
 
@@ -401,11 +442,16 @@ describe( 'Template', () => {
 			new Template( {
 				tag: 'div',
 				attributes: {
-					class: spy
+					class: {}
+				},
+				_modelBinders: {
+					attributes: {
+						class: spy
+					}
 				}
 			} ).apply( el );
 
-			sinon.assert.calledWithExactly( spy, el, sinon.match.func );
+			sinon.assert.calledWithExactly( spy, el, sinon.match.object );
 		} );
 
 		it( 'activates model bindings – children', () => {
@@ -418,13 +464,18 @@ describe( 'Template', () => {
 					{
 						tag: 'span',
 						attributes: {
-							class: spy
+							class: {}
+						},
+						_modelBinders: {
+							attributes: {
+								class: spy
+							}
 						}
 					}
 				]
 			} ).apply( el );
 
-			sinon.assert.calledWithExactly( spy, el.firstChild, sinon.match.func );
+			sinon.assert.calledWithExactly( spy, el.firstChild, sinon.match.object );
 		} );
 	} );
 } );

+ 358 - 91
packages/ckeditor5-engine/tests/ui/view.js

@@ -219,116 +219,379 @@ describe( 'View', () => {
 		} );
 	} );
 
-	describe( 'bindToAttribute', () => {
-		beforeEach( createViewInstanceWithTemplate );
+	describe( 'attributeBinder', () => {
+		it( 'provides "to" and "if" interface', () => {
+			const bind = view.attributeBinder;
 
-		it( 'returns a function that passes arguments', () => {
-			setTestViewInstance( { a: 'foo' } );
+			expect( bind ).to.have.keys( 'to', 'if' );
+			expect( typeof bind.to ).to.be.equal( 'function' );
+			expect( typeof bind.if ).to.be.equal( 'function' );
+		} );
 
-			const spy = testUtils.sinon.spy();
-			const callback = view.bindToAttribute( 'a', spy );
+		describe( 'to', () => {
+			it( 'returns an object which describes the binding', () => {
+				setTestViewInstance( { a: 1 } );
 
-			expect( spy.called ).to.be.false;
+				const spy = testUtils.sinon.spy();
+				const bind = view.attributeBinder;
+				const binding = bind.to( 'a', spy );
 
-			callback( 'el', 'updater' );
-			sinon.assert.calledOnce( spy );
-			sinon.assert.calledWithExactly( spy, 'el', 'foo' );
+				expect( spy.called ).to.be.false;
+				expect( binding ).to.have.keys( [ 'type', 'model', 'attribute', 'callback' ] );
+				expect( binding.model ).to.equal( view.model );
+				expect( binding.callback ).to.equal( spy );
+				expect( binding.attribute ).to.equal( 'a' );
+			} );
 
-			view.model.a = 'bar';
-			sinon.assert.calledTwice( spy );
-			expect( spy.secondCall.calledWithExactly( 'el', 'bar' ) ).to.be.true;
-		} );
+			it( 'allows binding attribute to the model – simple (HTMLElement attribute)', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
 
-		it( 'allows binding attribute to the model', () => {
-			setTestViewClass( function() {
-				return {
-					tag: 'p',
-					attributes: {
-						'class': this.bindToAttribute( 'foo' )
-					},
-					children: [ 'abc' ]
-				};
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo' )
+						},
+						children: [ 'abc' ]
+					};
+				} );
+
+				setTestViewInstance( { foo: 'bar' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p class="bar">abc</p>' );
+
+				view.model.foo = 'baz';
+				expect( view.element.outerHTML ).to.be.equal( '<p class="baz">abc</p>' );
 			} );
 
-			setTestViewInstance( { foo: 'bar' } );
+			it( 'allows binding attribute to the model – simple (Text Node)', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
 
-			expect( view.element.outerHTML ).to.be.equal( '<p class="bar">abc</p>' );
+					return {
+						tag: 'p',
+						children: [
+							{
+								text: bind.to( 'foo' )
+							}
+						]
+					};
+				} );
 
-			view.model.foo = 'baz';
-			expect( view.element.outerHTML ).to.be.equal( '<p class="baz">abc</p>' );
-		} );
+				setTestViewInstance( { foo: 'bar' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p>bar</p>' );
 
-		it( 'allows binding "text" to the model', () => {
-			setTestViewClass( function() {
-				return {
-					tag: 'p',
-					children: [
-						{
-							text: this.bindToAttribute( 'foo' )
+				view.model.foo = 'baz';
+				expect( view.element.outerHTML ).to.be.equal( '<p>baz</p>' );
+			} );
+
+			it( 'allows binding attribute to the model – value processing', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+
+					const callback = ( node, value ) =>
+						( value > 0 ? 'positive' : 'negative' );
+
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo', callback )
 						},
-						{
-							tag: 'b',
-							children: [ 'baz' ]
+						children: [
+							{
+								text: bind.to( 'foo', callback )
+							}
+						]
+					};
+				} );
+
+				setTestViewInstance( { foo: 3 } );
+				expect( view.element.outerHTML ).to.be.equal( '<p class="positive">positive</p>' );
+
+				view.model.foo = -7;
+				expect( view.element.outerHTML ).to.be.equal( '<p class="negative">negative</p>' );
+			} );
+
+			it( 'allows binding attribute to the model – custom callback', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo', ( el, value ) => {
+								el.innerHTML = value;
+
+								if ( value == 'changed' ) {
+									return value;
+								}
+							} )
 						}
-					]
-				};
+					};
+				} );
+
+				setTestViewInstance( { foo: 'moo' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p class="undefined">moo</p>' );
+
+				view.model.foo = 'changed';
+				expect( view.element.outerHTML ).to.be.equal( '<p class="changed">changed</p>' );
 			} );
 
-			setTestViewInstance( { foo: 'bar' } );
+			it( 'allows binding attribute to the model – array of bindings (HTMLElement attribute)', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+
+					return {
+						tag: 'p',
+						attributes: {
+							'class': [
+								'ck-class',
+								bind.to( 'foo' ),
+								bind.to( 'bar' ),
+								bind.to( 'foo', ( el, value ) => `foo-is-${value}` ),
+								'ck-end'
+							]
+						},
+						children: [ 'abc' ]
+					};
+				} );
 
-			expect( view.element.outerHTML ).to.be.equal( '<p>bar<b>baz</b></p>' );
+				setTestViewInstance( { foo: 'a', bar: 'b' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p class="ck-class a b foo-is-a ck-end">abc</p>' );
 
-			view.model.foo = 'qux';
-			expect( view.element.outerHTML ).to.be.equal( '<p>qux<b>baz</b></p>' );
-		} );
+				view.model.foo = 'c';
+				view.model.bar = 'd';
+				expect( view.element.outerHTML ).to.be.equal( '<p class="ck-class c d foo-is-c ck-end">abc</p>' );
+			} );
 
-		it( 'allows binding to the model with value processing', () => {
-			const callback = ( el, value ) =>
-				( value > 0 ? 'positive' : 'negative' );
+			it( 'allows binding attribute to the model – array of bindings (Text Node)', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
 
-			setTestViewClass( function() {
-				return {
-					tag: 'p',
-					attributes: {
-						'class': this.bindToAttribute( 'foo', callback )
-					},
-					children: [
-						{ text: this.bindToAttribute( 'foo', callback ) }
-					]
-				};
+					return {
+						tag: 'p',
+						attributes: {
+						},
+						children: [
+							{
+								text: [
+									'ck-class',
+									bind.to( 'foo' ),
+									bind.to( 'bar' ),
+									bind.to( 'foo', ( el, value ) => `foo-is-${value}` ),
+									'ck-end'
+								]
+							}
+						]
+					};
+				} );
+
+				setTestViewInstance( { foo: 'a', bar: 'b' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p>ck-class a b foo-is-a ck-end</p>' );
+
+				view.model.foo = 'c';
+				view.model.bar = 'd';
+				expect( view.element.outerHTML ).to.be.equal( '<p>ck-class c d foo-is-c ck-end</p>' );
 			} );
 
-			setTestViewInstance( { foo: 3 } );
-			expect( view.element.outerHTML ).to.be.equal( '<p class="positive">positive</p>' );
+			it( 'allows binding attribute to the model – falsy values', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
 
-			view.model.foo = -7;
-			expect( view.element.outerHTML ).to.be.equal( '<p class="negative">negative</p>' );
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo' )
+						},
+						children: [ 'abc' ]
+					};
+				} );
+
+				setTestViewInstance( { foo: 'bar' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p class="bar">abc</p>' );
+
+				view.model.foo = false;
+				expect( view.element.outerHTML ).to.be.equal( '<p class="false">abc</p>' );
+
+				view.model.foo = null;
+				expect( view.element.outerHTML ).to.be.equal( '<p class="null">abc</p>' );
+
+				view.model.foo = undefined;
+				expect( view.element.outerHTML ).to.be.equal( '<p class="undefined">abc</p>' );
+
+				view.model.foo = 0;
+				expect( view.element.outerHTML ).to.be.equal( '<p class="0">abc</p>' );
+
+				view.model.foo = '';
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</p>' );
+			} );
 		} );
 
-		it( 'allows binding to the model with custom callback', () => {
-			setTestViewClass( function() {
-				return {
-					tag: 'p',
-					attributes: {
-						'class': this.bindToAttribute( 'foo', ( el, value ) => {
-							el.innerHTML = value;
+		describe( 'if', () => {
+			it( 'returns an object which describes the binding', () => {
+				setTestViewInstance( { a: 1 } );
+
+				const spy = testUtils.sinon.spy();
+				const bind = view.attributeBinder;
+				const binding = bind.if( 'a', 'foo', spy );
+
+				expect( spy.called ).to.be.false;
+				expect( binding ).to.have.keys( [ 'type', 'model', 'attribute', 'callback', 'valueIfTrue' ] );
+				expect( binding.model ).to.equal( view.model );
+				expect( binding.callback ).to.equal( spy );
+				expect( binding.attribute ).to.equal( 'a' );
+				expect( binding.valueIfTrue ).to.equal( 'foo' );
+			} );
+
+			it( 'allows binding attribute to the model – presence of an attribute (HTMLElement attribute)', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
 
-							if ( value == 'changed' ) {
-								return value;
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo' )
+						},
+						children: [ 'abc' ]
+					};
+				} );
+
+				setTestViewInstance( { foo: true } );
+				expect( view.element.outerHTML ).to.be.equal( '<p class="">abc</p>' );
+
+				view.model.foo = false;
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</p>' );
+
+				view.model.foo = 'bar';
+				expect( view.element.outerHTML ).to.be.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 model – presence of an attribute (Text Node)', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+
+					return {
+						tag: 'p',
+						children: [
+							{
+								text: bind.if( 'foo' )
 							}
-						} )
-					},
-					children: [ 'bar' ]
-				};
+						]
+					};
+				} );
+
+				setTestViewInstance( { foo: true } );
+				expect( view.element.outerHTML ).to.be.equal( '<p></p>' );
+
+				view.model.foo = false;
+				expect( view.element.outerHTML ).to.be.equal( '<p></p>' );
+
+				view.model.foo = 'bar';
+				expect( view.element.outerHTML ).to.be.equal( '<p></p>' );
 			} );
 
-			setTestViewInstance( { foo: 'moo' } );
-			// Note: First the attribute binding sets innerHTML to 'moo',
-			// then 'bar' TextNode child is added.
-			expect( view.element.outerHTML ).to.be.equal( '<p>moobar</p>' );
+			it( 'allows binding attribute to the model – value of an attribute (HTMLElement attribute)', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'bar' )
+						},
+						children: [ 'abc' ]
+					};
+				} );
 
-			view.model.foo = 'changed';
-			expect( view.element.outerHTML ).to.be.equal( '<p class="changed">changed</p>' );
+				setTestViewInstance( { foo: 'bar' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p class="bar">abc</p>' );
+
+				view.model.foo = false;
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</p>' );
+
+				view.model.foo = 64;
+				expect( view.element.outerHTML ).to.be.equal( '<p class="bar">abc</p>' );
+			} );
+
+			it( 'allows binding attribute to the model – value of an attribute (Text Node)', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+
+					return {
+						tag: 'p',
+						children: [
+							{
+								text: bind.if( 'foo', 'bar' )
+							}
+						]
+					};
+				} );
+
+				setTestViewInstance( { foo: 'bar' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p>bar</p>' );
+
+				view.model.foo = false;
+				expect( view.element.outerHTML ).to.be.equal( '<p></p>' );
+
+				view.model.foo = 64;
+				expect( view.element.outerHTML ).to.be.equal( '<p>bar</p>' );
+			} );
+
+			it( 'allows binding attribute to the model – value of an attribute processed by a callback', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'there–is–no–foo', ( el, value ) => !value )
+						},
+						children: [ 'abc' ]
+					};
+				} );
+
+				setTestViewInstance( { foo: 'bar' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</p>' );
+
+				view.model.foo = false;
+				expect( view.element.outerHTML ).to.be.equal( '<p class="there–is–no–foo">abc</p>' );
+
+				view.model.foo = 64;
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</p>' );
+			} );
+
+			it( 'allows binding attribute to the model – falsy values', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'foo-is-set' )
+						},
+						children: [ 'abc' ]
+					};
+				} );
+
+				setTestViewInstance( { foo: 'bar' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p class="foo-is-set">abc</p>' );
+
+				view.model.foo = false;
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</p>' );
+
+				view.model.foo = null;
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</p>' );
+
+				view.model.foo = undefined;
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</p>' );
+
+				view.model.foo = '';
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</p>' );
+
+				view.model.foo = 0;
+				expect( view.element.outerHTML ).to.be.equal( '<p class="foo-is-set">abc</p>' );
+			} );
 		} );
 	} );
 
@@ -596,10 +859,12 @@ describe( 'View', () => {
 
 		it( 'detaches bound model listeners', () => {
 			setTestViewClass( function() {
+				const bind = this.attributeBinder;
+
 				return {
 					tag: 'p',
 					children: [
-						{ text: this.bindToAttribute( 'foo' ) }
+						{ text: bind.to( 'foo' ) }
 					]
 				};
 			} );
@@ -637,21 +902,22 @@ describe( 'View', () => {
 
 		it( 'should initialize attribute bindings', () => {
 			const el = document.createElement( 'div' );
+			const bind = view.attributeBinder;
 
 			view.applyTemplateToElement( el, {
 				tag: 'div',
 				attributes: {
-					class: view.bindToAttribute( 'b' ),
+					class: bind.to( 'b' ),
 					id: 'foo',
-					checked: ''
+					checked: 'checked'
 				}
 			} );
 
-			expect( el.outerHTML ).to.be.equal( '<div class="42" id="foo" checked=""></div>' );
+			expect( el.outerHTML ).to.be.equal( '<div class="42" id="foo" checked="checked"></div>' );
 
 			view.model.b = 64;
 
-			expect( el.outerHTML ).to.be.equal( '<div class="64" id="foo" checked=""></div>' );
+			expect( el.outerHTML ).to.be.equal( '<div class="64" id="foo" checked="checked"></div>' );
 		} );
 
 		it( 'should initialize DOM listeners', () => {
@@ -693,6 +959,7 @@ describe( 'View', () => {
 			const spy1 = testUtils.sinon.spy();
 			const spy2 = testUtils.sinon.spy();
 			const spy3 = testUtils.sinon.spy();
+			const bind = view.attributeBinder;
 
 			view.applyTemplateToElement( el, {
 				tag: 'div',
@@ -703,7 +970,7 @@ describe( 'View', () => {
 							keyup: spy2
 						},
 						attributes: {
-							class: view.bindToAttribute( 'b', ( el, b ) => 'applied-A-' + b ),
+							class: bind.to( 'b', ( el, b ) => 'applied-A-' + b ),
 							id: 'applied-A'
 						},
 						children: [ 'Text applied to childA.' ]
@@ -714,7 +981,7 @@ describe( 'View', () => {
 							keydown: spy3
 						},
 						attributes: {
-							class: view.bindToAttribute( 'b', ( el, b ) => 'applied-B-' + b ),
+							class: bind.to( 'b', ( el, b ) => 'applied-B-' + b ),
 							id: 'applied-B'
 						},
 						children: [ 'Text applied to childB.' ]
@@ -725,8 +992,8 @@ describe( 'View', () => {
 					'mouseover@a': spy1
 				},
 				attributes: {
-					id: view.bindToAttribute( 'a', ( el, a ) => a.toUpperCase() ),
-					class: view.bindToAttribute( 'b', ( el, b ) => 'applied-parent-' + b )
+					id: bind.to( 'a', ( el, a ) => a.toUpperCase() ),
+					class: bind.to( 'b', ( el, b ) => 'applied-parent-' + b )
 				}
 			} );
 
@@ -788,7 +1055,7 @@ describe( 'View', () => {
 			sinon.assert.calledTwice( spy );
 		} );
 
-		it( 'shares a template definition between View instances', () => {
+		it( 'shares a template definition between View instances – event listeners', () => {
 			const el = document.createElement( 'div' );
 			const spy = testUtils.sinon.spy();
 			const view1 = new View();