Bläddra i källkod

Merge branch 'master' into t/190

Piotrek Koszuliński 9 år sedan
förälder
incheckning
ef8486b8e5

+ 5 - 8
packages/ckeditor5-ui/CHANGES.md

@@ -1,9 +1,9 @@
-{App Name} Changelog
-====================
+CKEditor 5 Core Framework Changelog
+========================================
 
-## App 1.2.3
+## CKEditor 5 Core Framework 0.0.2
 
-**Major|Minor|Patch Release** - Build Date: yyyy-mm-dd
+**Major|Minor|Patch Release**  Build Date: yyyy-mm-dd
 
 New Features:
 
@@ -15,14 +15,11 @@ Fixed Issues:
 * [#Issue Number](http://issue/url): Item 1.
 * Item 2
 
-
 Other Changes:
 
 * Item 1
 * Item 2
 
----
-
-## App 1.2.2
+## CKEditor 5 Core Framework 0.0.1
 
 ...

+ 2 - 3
packages/ckeditor5-ui/CONTRIBUTING.md

@@ -1,5 +1,4 @@
 Contributing
-============
+========================================
 
-Information about contributing can be found at the following page:
-https://github.com/ckeditor/ckeditor5/blob/master/CONTRIBUTING.md
+Information about contributing can be found at the following page: <https://github.com/ckeditor/ckeditor5/blob/master/CONTRIBUTING.md>.

+ 10 - 18
packages/ckeditor5-ui/LICENSE.md

@@ -1,37 +1,29 @@
 Software License Agreement
 ==========================
 
-**CKEditor 5 Core Framework** - https://github.com/ckeditor/ckeditor5-core <br>
+**CKEditor 5 Core Framework**  https://github.com/ckeditor/ckeditor5-core <br>
 Copyright (c) 2003-2016, [CKSource](http://cksource.com) Frederico Knabben. All rights reserved.
 
 Licensed under the terms of any of the following licenses at your choice:
 
- * [GNU General Public License Version 2 or later (the "GPL")](http://www.gnu.org/licenses/gpl.html)
+* [GNU General Public License Version 2 or later (the "GPL")](http://www.gnu.org/licenses/gpl.html)
+* [GNU Lesser General Public License Version 2.1 or later (the "LGPL")](http://www.gnu.org/licenses/lgpl.html)
+* [Mozilla Public License Version 1.1 or later (the "MPL")](http://www.mozilla.org/MPL/MPL-1.1.html)
 
- * [GNU Lesser General Public License Version 2.1 or later (the "LGPL")](http://www.gnu.org/licenses/lgpl.html)
-
- * [Mozilla Public License Version 1.1 or later (the "MPL")](http://www.mozilla.org/MPL/MPL-1.1.html)
-
-You are not required to, but if you want to explicitly declare the license you have chosen to be bound to when using,
-reproducing, modifying and distributing this software, just include a text file titled "legal.txt" in your version of
-this software, indicating your license choice. In any case, your choice will not restrict any recipient of your version
-of this software to use, reproduce, modify and distribute this software under any of the above licenses.
+You are not required to, but if you want to explicitly declare the license you have chosen to be bound to when using, reproducing, modifying and distributing this software, just include a text file titled "legal.txt" in your version of this software, indicating your license choice. In any case, your choice will not restrict any recipient of your version of this software to use, reproduce, modify and distribute this software under any of the above licenses.
 
 Sources of Intellectual Property Included in CKEditor
 -----------------------------------------------------
 
-Where not otherwise indicated, all CKEditor content is authored by CKSource engineers and consists of CKSource-owned
-intellectual property. In some specific instances, CKEditor will incorporate work done by developers outside of CKSource
-with their express permission.
+Where not otherwise indicated, all CKEditor content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor will incorporate work done by developers outside of CKSource with their express permission.
 
 Third parties' software included:
 
- * Lo-Dash (src/lib/lodash) <br>
-   Copyright 2012-2015 [The Dojo Foundation](http://dojofoundation.org/) <br>
-   Available under [MIT license](http://lodash.com/license)
+* Lo-Dash (src/lib/lodash) <br>
+  Copyright 2012-2015 [The Dojo Foundation](http://dojofoundation.org/) <br>
+  Available under [MIT license](http://lodash.com/license)
 
 Trademarks
 ----------
 
-**CKEditor** is a trademark of [CKSource](http://cksource.com) Frederico Knabben. All other brand and product names are
-trademarks, registered trademarks or service marks of their respective holders.
+**CKEditor** is a trademark of [CKSource](http://cksource.com) Frederico Knabben. All other brand and product names are trademarks, registered trademarks or service marks of their respective holders.

+ 2 - 3
packages/ckeditor5-ui/README.md

@@ -1,10 +1,9 @@
 CKEditor 5 Core Framework
-================================
+========================================
 
 [![devDependency Status](https://david-dm.org/ckeditor/ckeditor5-core/dev-status.svg)](https://david-dm.org/ckeditor/ckeditor5-core#info=devDependencies)
 
-The core framework of CKEditor 5 project. More information about the project can be found at the following url: <br>
-https://github.com/ckeditor/ckeditor5
+The core framework of CKEditor 5 project. More information about the project can be found at the following url: <https://github.com/ckeditor/ckeditor5>.
 
 ## License
 

+ 5 - 4
packages/ckeditor5-ui/src/ckeditorerror.js

@@ -13,17 +13,15 @@
  *
  * Read more in the {@link core.log} module.
  *
- * @class CKEditorError
+ * @memberOf core
  * @extends Error
  */
-
 export default class CKEditorError extends Error {
 	/**
 	 * Creates an instance of the CKEditorError class.
 	 *
 	 * Read more about error logging in the {@link core.log} module.
 	 *
-	 * @constructor
 	 * @param {String} message The error message in an `error-name: Error message.` format.
 	 * During the minification process the "Error message" part will be removed to limit the code size
 	 * and a link to this error documentation will be added to the `message`.
@@ -38,12 +36,15 @@ export default class CKEditorError extends Error {
 
 		super( message );
 
+		/**
+		 * @member {String} core.CKEditorError#name
+		 */
 		this.name = 'CKEditorError';
 
 		/**
 		 * The additional error data passed to the constructor.
 		 *
-		 * @property {Object} data
+		 * @member {Object} core.CKEditorError#data
 		 */
 		this.data = data;
 	}

+ 10 - 10
packages/ckeditor5-ui/src/collection.js

@@ -19,16 +19,14 @@ import utils from './utils.js';
  * By default an item in the collection is identified by its `id` property. The name of the identifier can be
  * configured through the constructor of the collection.
  *
- * @class Collection
- * @mixins EventEmitter
+ * @memberOf core
+ * @mixes EventEmitter
  */
-
 export default class Collection {
 	/**
 	 * Creates a new Collection instance.
 	 *
-	 * @constructor
-	 * @param {Iterale} [items] Items to be added to the collection.
+	 * @param {Iterable} [items] Items to be added to the collection.
 	 * @param {Object} options The options object.
 	 * @param {String} [options.idProperty='id'] The name of the property which is considered to identify an item.
 	 */
@@ -37,7 +35,7 @@ export default class Collection {
 		 * The internal list of items in the collection.
 		 *
 		 * @private
-		 * @property {Object[]}
+		 * @type {Object[]}
 		 */
 		this._items = [];
 
@@ -45,7 +43,7 @@ export default class Collection {
 		 * The internal map of items in the collection.
 		 *
 		 * @private
-		 * @property {Map}
+		 * @type {Map}
 		 */
 		this._itemMap = new Map();
 
@@ -53,7 +51,7 @@ export default class Collection {
 		 * The name of the property which is considered to identify an item.
 		 *
 		 * @private
-		 * @property {String}
+		 * @type {String}
 		 */
 		this._idProperty = options && options.idProperty || 'id';
 	}
@@ -76,6 +74,7 @@ export default class Collection {
 	 * @param {Object} item
 	 * @param {Number} [index] The position of the item in the collection. The item
 	 * is pushed to the collection when `index` not specified.
+	 * @fires {@link core.Collection.add add}
 	 */
 	add( item, index ) {
 		let itemId;
@@ -157,6 +156,7 @@ export default class Collection {
 	 *
 	 * @param {Object|Number|String} subject The item to remove, its id or index in the collection.
 	 * @returns {Object} The removed item.
+	 * @fires {@link core.Collection.remove remove}
 	 */
 	remove( subject ) {
 		let index, id, item;
@@ -270,13 +270,13 @@ utils.mix( Collection, EmitterMixin );
 /**
  * Fired when an item is added to the collection.
  *
- * @event add
+ * @event core.Collection.add
  * @param {Object} item The added item.
  */
 
 /**
  * Fired when an item is removed from the collection.
  *
- * @event remove
+ * @event core.Collection.remove
  * @param {Object} item The removed item.
  */

+ 4 - 7
packages/ckeditor5-ui/src/config.js

@@ -13,16 +13,14 @@ import utils from './utils.js';
 /**
  * Handles a configuration dictionary.
  *
- * @class core.Config
- * @mixins core.ObservableMixin
+ * @memberOf core
+ * @mixes core.ObservableMixin
  */
-
 export default class Config {
 	/**
 	 * Creates an instance of the {@link Config} class.
 	 *
 	 * @param {Object} [configurations] The initial configurations to be set.
-	 * @constructor
 	 */
 	constructor( configurations ) {
 		if ( configurations ) {
@@ -63,7 +61,7 @@ export default class Config {
 	 *     config.toolbar.collapsed; // true
 	 *     config.toolbar.color; // 'red'
 	 *
-	 * @param {String|Object} nameOrConfigurations The configuration name or an object from which take properties as
+	 * @param {String|Object} name The configuration name or an object from which take properties as
 	 * configuration entries. Configuration names are case-insensitive.
 	 * @param {*} [value=null] The configuration value. Used if a name is passed to nameOrConfigurations.
 	 */
@@ -171,7 +169,7 @@ export default class Config {
 	 * This method is supposed to be called by plugin developers to setup plugin's configurations. It would be
 	 * rarely used for other needs.
 	 *
-	 * @param {String|Object} nameOrConfigurations The configuration name or an object from which take properties as
+	 * @param {String|Object} name The configuration name or an object from which take properties as
 	 * configuration entries.
 	 * @param {*} [value] The configuration value. Used if a name is passed to nameOrConfigurations. If undefined,
 	 * the configuration is set to `null`.
@@ -181,7 +179,6 @@ export default class Config {
 			/**
 			 * TODO
 			 *
-			 * @property
 			 * @type {Config}
 			 */
 			this.definition = new Config();

+ 8 - 0
packages/ckeditor5-ui/src/editable.jsdoc

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @namespace core.editable
+ */

+ 6 - 10
packages/ckeditor5-ui/src/editable/editable.js

@@ -11,15 +11,15 @@ import utils from '../utils.js';
 import ObservableMixin from '../observablemixin.js';
 
 /**
- * @class core.editable.Editable
+ * @memberOf core.editable
  * @extends core.ui.Controller
- * @mixins core.ObservableMixin
+ * @mixes core.ObservableMixin
  */
 export default class Editable extends Controller {
 	/**
 	 * Creates a new instance of the Editable class.
 	 *
-	 * @constructor
+	 * @param editor
 	 */
 	constructor( editor ) {
 		super();
@@ -29,7 +29,7 @@ export default class Editable extends Controller {
 		/**
 		 * Whether the editable is in read-write or read-only mode.
 		 *
-		 * @property {Boolean} isEditable
+		 * @member {Boolean} core.editable.Editable#isEditable
 		 */
 		this.set( 'isEditable', true );
 
@@ -37,20 +37,16 @@ export default class Editable extends Controller {
 		 * Whether the editable is focused.
 		 *
 		 * @readonly
-		 * @property {Boolean} isFocused
+		 * @member {Boolean} core.editable.Editable#isFocused
 		 */
 		this.set( 'isFocused', false );
-
-		/**
-		 * @private {Model} _viewModel
-		 */
 	}
 
 	/**
 	 * The model for the editable view.
 	 *
 	 * @readonly
-	 * @property {core.ui.Model} viewModel
+	 * @type {core.ui.Model}
 	 */
 	get viewModel() {
 		if ( this._viewModel ) {

+ 11 - 2
packages/ckeditor5-ui/src/editable/editableview.js

@@ -8,15 +8,24 @@
 import View from '../ui/view.js';
 import CKEditorError from '../ckeditorerror.js';
 
+/**
+ * @memberOf core.editable
+ * @extends core.ui.View
+ */
 export default class EditableView extends View {
 	/**
 	 * The element which is the main editable element (usually the one with `contentEditable="true"`).
 	 *
 	 * @readonly
-	 * @property {HTMLElement} editableElement
+	 * @member {HTMLElement} core.editable.EditableView#editableElement
 	 */
 
+	/**
+	 * @param {HTMLElement} editableElement
+	 */
 	setEditableElement( editableElement ) {
+		const bind = this.attributeBinder;
+
 		if ( this.editableElement ) {
 			throw new CKEditorError(
 				'editableview-cannot-override-editableelement: The editableElement cannot be overriden.'
@@ -36,7 +45,7 @@ export default class EditableView extends View {
 			},
 
 			attributes: {
-				contentEditable: this.bindToAttribute( 'isEditable' )
+				contentEditable: bind.to( 'isEditable' )
 			}
 		} );
 	}

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

@@ -12,9 +12,9 @@ import ObservableMixin from './observablemixin.js';
 /**
  * Base class for the editor main view controllers.
  *
- * @class core.EditorUI
+ * @memberOf core
  * @extends core.ui.Controller
- * @mixins core.ObservableMixin
+ * @mixes core.ObservableMixin
  */
 
 export default class EditorUI extends Controller {
@@ -23,7 +23,7 @@ export default class EditorUI extends Controller {
 
 		/**
 		 * @readonly
-		 * @property {core.Editor}
+		 * @member {core.Editor} core.EditorUI.editor
 		 */
 		this.editor = editor;
 	}

+ 8 - 4
packages/ckeditor5-ui/src/emittermixin.js

@@ -15,11 +15,9 @@ let eventsCounter = 0;
 /**
  * Mixin that injects the events API into its host.
  *
- * @singleton
- * @class core.EmitterMixin
- * @implementes core.Emitter
+ * @mixin core.EmitterMixin
+ * @implements core.Emitter
  */
-
 const EmitterMixin = {
 	/**
 	 * Registers a callback function to be executed when an event is fired.
@@ -30,6 +28,7 @@ const EmitterMixin = {
 	 * event.
 	 * @param {Number} [priority=10] The priority of this callback in relation to other callbacks to that same event.
 	 * Lower values are called first.
+	 * @method core.EmitterMixin#on
 	 */
 	on( event, callback, ctx, priority ) {
 		const callbacks = getCallbacks( this, event );
@@ -69,6 +68,7 @@ const EmitterMixin = {
 	 * event.
 	 * @param {Number} [priority=10] The priority of this callback in relation to other callbacks to that same event.
 	 * Lower values are called first.
+	 * @method core.EmitterMixin#once
 	 */
 	once( event, callback, ctx, priority ) {
 		const onceCallback = function( event ) {
@@ -90,6 +90,7 @@ const EmitterMixin = {
 	 * @param {Function} callback The function to stop being called.
 	 * @param {Object} [ctx] The context object to be removed, pared with the given callback. To handle cases where
 	 * the same callback is used several times with different contexts.
+	 * @method core.EmitterMixin#off
 	 */
 	off( event, callback, ctx ) {
 		const callbacks = getCallbacksIfAny( this, event );
@@ -118,6 +119,7 @@ const EmitterMixin = {
 	 * @param {Object} [ctx] The object that represents `this` in the callback. Defaults to `emitter`.
 	 * @param {Number} [priority=10] The priority of this callback in relation to other callbacks to that same event.
 	 * Lower values are called first.
+	 * @method core.EmitterMixin#listenTo
 	 */
 	listenTo( emitter, event, callback, ctx, priority ) {
 		let emitters, emitterId, emitterInfo, eventCallbacks;
@@ -174,6 +176,7 @@ const EmitterMixin = {
 	 * for all events from `emitter`.
 	 * @param {Function} [callback] (Requires the `event`) The function to be removed from the call list for the given
 	 * `event`.
+	 * @method core.EmitterMixin#stopListening
 	 */
 	stopListening( emitter, event, callback ) {
 		let emitters = this._listeningTo;
@@ -221,6 +224,7 @@ const EmitterMixin = {
 	 *
 	 * @param {String} event The name of the event.
 	 * @param {...*} [args] Additional arguments to be passed to the callbacks.
+	 * @method core.EmitterMixin#fire
 	 */
 	fire( event, args ) {
 		const callbacks = getCallbacksIfAny( this, event );

+ 7 - 4
packages/ckeditor5-ui/src/eventinfo.js

@@ -11,18 +11,21 @@ import utils from './utils.js';
  * The event object passed to event callbacks. It is used to provide information about the event as well as a tool to
  * manipulate it.
  *
- * @class EventInfo
+ * @memberOf core
  */
-
 export default class EventInfo {
 	constructor( source, name ) {
 		/**
 		 * The object that fired the event.
+		 *
+		 * @member core.EventInfo#source
 		 */
 		this.source = source;
 
 		/**
 		 * The event name.
+		 *
+		 * @member core.EventInfo#name
 		 */
 		this.name = name;
 
@@ -31,14 +34,14 @@ export default class EventInfo {
 		/**
 		 * Stops the event emitter to call further callbacks for this event interaction.
 		 *
-		 * @method
+		 * @method core.EventInfo#stop
 		 */
 		this.stop = utils.spy();
 
 		/**
 		 * Removes the current callback from future interactions of this event.
 		 *
-		 * @method
+		 * @method core.EventInfo#off
 		 */
 		this.off = utils.spy();
 	}

+ 4 - 4
packages/ckeditor5-ui/src/log.js

@@ -35,10 +35,8 @@
  * log an error.
  * * Whenever it's really bad and it does not make sense to continue working, throw a {@link core.CKEditorError}.
  *
- * @class log
- * @singleton
+ * @namespace core.log
  */
-
 const log = {
 	/**
 	 * Logs an error to the console.
@@ -49,6 +47,7 @@ const log = {
 	 * During the minification process the "Error message" part will be removed to limit the code size
 	 * and a link to this error documentation will be logged to the console.
 	 * @param {Object} [data] Additional data describing the error.
+	 * @method core.log.error
 	 */
 	error( message, data ) {
 		console.error( message, data );
@@ -63,10 +62,11 @@ const log = {
 	 * During the minification process the "Warning message" part will be removed to limit the code size
 	 * and a link to this error documentation will be logged to the console.
 	 * @param {Object} [data] Additional data describing the warning.
+	 * @method core.log.warn
 	 */
 	warn( message, data ) {
 		console.warn( message, data );
 	}
 };
 
-export default log;
+export default log;

+ 25 - 21
packages/ckeditor5-ui/src/observablemixin.js

@@ -18,12 +18,10 @@ const boundAttributesSymbol = Symbol( 'boundAttributes' );
  * Mixin that injects the "observable attributes" and data binding functionality.
  * Used mainly in the {@link core.ui.Model} class.
  *
- * @singleton
- * @class core.ObservableMixin
- * @mixins EmitterMixin
+ * @mixin core.ObservableMixin
+ * @mixes core.EmitterMixin
  * @implements core.Observable
  */
-
 const ObservableMixin = {
 	/**
 	 * Creates and sets the value of an observable attribute of this object. Such an attribute becomes a part
@@ -35,6 +33,7 @@ const ObservableMixin = {
 	 * have a property with a given attribute name. This prevents from mistakenly overriding existing
 	 * properties and methods, but means that `foo.set( 'bar', 1 )` may be slightly slower than `foo.bar = 1`.
 	 *
+	 * @method core.ObservableMixin#set
 	 * @param {String} name The attributes name.
 	 * @param {*} value The attributes value.
 	 */
@@ -103,15 +102,16 @@ const ObservableMixin = {
 	 * of the observable it is bound to and react to the changes to these attributes
 	 * in the future.
 	 *
-	 * **Note**: To release the binding use {@link #unbind}.
+	 * **Note**: To release the binding use {@link core.ObservableMixin#unbind}.
 	 *
 	 *		A.bind( 'a' ).to( B );
 	 *		A.bind( 'a' ).to( B, 'b' );
 	 *		A.bind( 'a', 'b' ).to( B, 'c', 'd' );
 	 *		A.bind( 'a' ).to( B, 'b', C, 'd', ( b, d ) => b + d );
 	 *
+	 * @method core.ObservableMixin#bind
 	 * @param {String...} bindAttrs Observable attributes that will be bound to another observable(s).
-	 * @returns {BindChain}
+	 * @returns {core.BindChain}
 	 */
 	bind( ...bindAttrs ) {
 		if ( !bindAttrs.length || !isStringArray( bindAttrs ) ) {
@@ -150,7 +150,7 @@ const ObservableMixin = {
 		const bindings = new Map();
 
 		/**
-		 * @typedef Binding
+		 * @typedef core.Binding
 		 * @type Object
 		 * @property {Array} attr Attribute which is bound.
 		 * @property {Array} to Array of observable–attribute components of the binding (`{ observable: ..., attr: .. }`).
@@ -164,13 +164,14 @@ const ObservableMixin = {
 		} );
 
 		/**
-		 * @typedef BindChain
+		 * @typedef core.BindChain
 		 * @type Object
-		 * @property {Function} to See {@link #_bindTo}.
+		 * @property {Function} to See {@link core.ObservableMixin#_bindTo}.
 		 * @property {Observable} _observable The observable which initializes the binding.
 		 * @property {Array} _bindAttrs Array of `_observable` attributes to be bound.
 		 * @property {Array} _to Array of `to()` observable–attributes (`{ observable: toObservable, attrs: ...toAttrs }`).
-		 * @property {Map} _bindings Stores bindings to be kept in {@link #_boundAttributes}/{@link #_boundObservables}
+		 * @property {Map} _bindings Stores bindings to be kept in
+		 *  {@link core.ObservableMixin#_boundAttributes}/{@link core.ObservableMixin#_boundObservables}
 		 * initiated in this binding chain.
 		 */
 		return {
@@ -184,12 +185,13 @@ const ObservableMixin = {
 	},
 
 	/**
-	 * Removes the binding created with {@link #bind}.
+	 * Removes the binding created with {@link core.ObservableMixin#bind}.
 	 *
 	 *		A.unbind( 'a' );
 	 *		A.unbind();
 	 *
-	 * @param {String...} [bindAttrs] Observable attributes to be unbound. All the bindings will
+	 * @method core.ObservableMixin#unbind
+	 * @param {String} [unbindAttrs] Observable attributes to be unbound. All the bindings will
 	 * be released if no attributes provided.
 	 */
 	unbind( ...unbindAttrs ) {
@@ -262,15 +264,15 @@ function initObservable( observable ) {
 	// The internal hash containing the observable's state.
 	//
 	// @private
-	// @property {Map} _attributes
+	// @type {Map}
 	Object.defineProperty( observable, attributesSymbol, {
 		value: new Map()
 	} );
 
 	// Map containing bindings to external observables. It shares the binding objects
-	// (`{ observable: A, attr: 'a', to: ... }`) with {@link #_boundAttributes} and
+	// (`{ observable: A, attr: 'a', to: ... }`) with {@link core.ObservableMixin#_boundAttributes} and
 	// it is used to observe external observables to update own attributes accordingly.
-	// See {@link #bind}.
+	// See {@link core.ObservableMixin#bind}.
 	//
 	//		A.bind( 'a', 'b', 'c' ).to( B, 'x', 'y', 'x' );
 	//		console.log( A._boundObservables );
@@ -311,15 +313,17 @@ function initObservable( observable ) {
 	//			} )
 	//
 	// @private
-	// @property {Map} _boundObservables
+	// @type {Map}
 	Object.defineProperty( observable, boundObservablesSymbol, {
 		value: new Map()
 	} );
 
 	// Object that stores which attributes of this observable are bound and how. It shares
-	// the binding objects (`{ observable: A, attr: 'a', to: ... }`) with {@link #_boundObservables}.
-	// This data structure is a reverse of {@link #_boundObservables} and it is helpful for {@link #unbind}.
-	// See {@link #bind}.
+	// the binding objects (`{ observable: A, attr: 'a', to: ... }`) with {@link core.ObservableMixin#_boundObservables}.
+	// This data structure is a reverse of {@link core.ObservableMixin#_boundObservables} and it is helpful for
+	// {@link core.ObservableMixin#unbind}.
+	//
+	// See {@link core.ObservableMixin#bind}.
 	//
 	//		A.bind( 'a', 'b', 'c' ).to( B, 'x', 'y', 'x' );
 	//		console.log( A._boundAttributes );
@@ -341,13 +345,13 @@ function initObservable( observable ) {
 	//			} )
 	//
 	// @private
-	// @property {Map} _boundAttributes
+	// @type {Map}
 	Object.defineProperty( observable, boundAttributesSymbol, {
 		value: new Map()
 	} );
 }
 
-// A chaining for {@link #bind} providing `.to()` interface.
+// A chaining for {@link core.ObservableMixin#bind} providing `.to()` interface.
 //
 // @private
 // @param {...[Observable|String|Function]} args Arguments of the `.to( args )` binding.

+ 8 - 0
packages/ckeditor5-ui/src/ui.jsdoc

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @namespace core.ui
+ */

+ 9 - 10
packages/ckeditor5-ui/src/ui/controller.js

@@ -13,37 +13,36 @@ import utils from '../utils.js';
 /**
  * Basic Controller class.
  *
- * @class Controller
- * @mixins EmitterMixin
+ * @memberOf core.ui
+ * @mixes core.EmitterMixin
  */
 
 export default class Controller {
 	/**
-	 * Creates an instance of the {@link Controller} class.
+	 * Creates an instance of the {@link core.ui.Controller} class.
 	 *
-	 * @param {Model} [model] Model of this Controller.
-	 * @param {View} [view] View instance of this Controller.
-	 * @constructor
+	 * @param {core.ui.Model} [model] Model of this Controller.
+	 * @param {core.ui.View} [view] View instance of this Controller.
 	 */
 	constructor( model, view ) {
 		/**
 		 * Model of this controller.
 		 *
-		 * @property {Model}
+		 * @member {core.ui.Model} core.ui.Controller#model
 		 */
 		this.model = model || null;
 
 		/**
 		 * Set `true` after {@link #init}.
 		 *
-		 * @property {Boolean}
+		 * @member {Boolean} core.ui.Controller#ready
 		 */
 		this.ready = false;
 
 		/**
 		 * View of this controller.
 		 *
-		 * @property {View}
+		 * @member {core.ui.View} core.ui.Controller#view
 		 */
 		this.view = view || null;
 
@@ -51,7 +50,7 @@ export default class Controller {
 		 * A collection of {@link ControllerCollection} instances containing
 		 * child controllers.
 		 *
-		 * @property {Collection}
+		 * @member {core.Collection} core.ui.Controller#collections
 		 */
 		this.collections = new Collection( {
 			idProperty: 'name'

+ 3 - 5
packages/ckeditor5-ui/src/ui/controllercollection.js

@@ -17,8 +17,6 @@ import CKEditorError from '../ckeditorerror.js';
 export default class ControllerCollection extends Collection {
 	/**
 	 * Creates an instance of the ControllerCollection class, initializing it with a name.
-	 *
-	 * @constructor
 	 */
 	constructor( name ) {
 		super();
@@ -35,14 +33,14 @@ export default class ControllerCollection extends Collection {
 		/**
 		 * Name of this collection.
 		 *
-		 * @property {String}
+		 * @type {String}
 		 */
 		this.name = name;
 
 		/**
 		 * Parent controller of this collection.
 		 *
-		 * @property {Controller}
+		 * @type {core.ui.Controller}
 		 */
 		this.parent = null;
 	}
@@ -51,7 +49,7 @@ export default class ControllerCollection extends Collection {
 	 * Adds a child controller to the collection. If {@link #parent} {@link Controller}
 	 * instance is ready, the child view is initialized when added.
 	 *
-	 * @param {Controller} controller A child controller.
+	 * @param {core.ui.Controller} controller A child controller.
 	 * @param {Number} [index] Index at which the child will be added to the collection.
 	 * @returns {Promise} A Promise resolved when the child {@link Controller#init} is done.
 	 */

+ 40 - 32
packages/ckeditor5-ui/src/ui/domemittermixin.js

@@ -14,11 +14,10 @@ import log from '../log.js';
  * Creates a ProxyEmitter instance. Such an instance is a bridge between a DOM Node firing events
  * and any Host listening to them. It is backwards compatible with {@link EmitterMixin#on}.
  *
- * @class DOMEmitterMixin
- * @mixins EmitterMixin
- * @implements DOMEmitter
+ * @memberOf core.ui
+ * @mixes core.EmitterMixin
+ * @implements core.ui.DOMEmitter
  */
-
 class ProxyEmitter {
 	/**
 	 * @param {Node} node DOM Node that fires events.
@@ -38,7 +37,7 @@ extend( ProxyEmitter.prototype, EmitterMixin, {
 	 * Collection of native DOM listeners.
 	 *
 	 * @private
-	 * @property {Object} _domListeners
+	 * @member {Object} core.ui.ProxyEmitter#_domListeners
 	 */
 
 	/**
@@ -53,6 +52,8 @@ extend( ProxyEmitter.prototype, EmitterMixin, {
 	 * event.
 	 * @param {Number} [priority=10] The priority of this callback in relation to other callbacks to that same event.
 	 * Lower values are called first.
+	 *
+	 * @method core.ui.ProxyEmitter#on
 	 */
 	on( event ) {
 		// Execute parent class method first.
@@ -85,6 +86,8 @@ extend( ProxyEmitter.prototype, EmitterMixin, {
 	 * @param {Function} callback The function to stop being called.
 	 * @param {Object} [ctx] The context object to be removed, pared with the given callback. To handle cases where
 	 * the same callback is used several times with different contexts.
+	 *
+	 * @method core.ui.ProxyEmitter#off
 	 */
 	off( event ) {
 		// Execute parent class method first.
@@ -106,6 +109,8 @@ extend( ProxyEmitter.prototype, EmitterMixin, {
 	 * Note: A native DOM Event is passed as an argument.
 	 *
 	 * @param {String} event
+	 *
+	 * @method core.ui.ProxyEmitter#_createDomListener
 	 * @returns {Function} The DOM listener callback.
 	 */
 	_createDomListener( event ) {
@@ -129,38 +134,36 @@ extend( ProxyEmitter.prototype, EmitterMixin, {
  * Mixin that injects the DOM events API into its host. It provides the API
  * compatible with {@link EmitterMixin}.
  *
- *                               listenTo( click, ... )
- *                 +-----------------------------------------+
- *                 |              stopListening( ... )       |
- *  +----------------------------+                           |             addEventListener( click, ... )
- *  | Host                       |                           |   +---------------------------------------------+
- *  +----------------------------+                           |   |       removeEventListener( click, ... )     |
- *  | _listeningTo: {            |                +----------v-------------+                                   |
- *  |   UID: {                   |                | ProxyEmitter           |                                   |
- *  |     emitter: ProxyEmitter, |                +------------------------+                      +------------v----------+
- *  |     callbacks: {           |                | events: {              |                      | Node (HTMLElement)    |
- *  |       click: [ callbacks ] |                |   click: [ callbacks ] |                      +-----------------------+
- *  |     }                      |                | },                     |                      | data-cke-expando: UID |
- *  |   }                        |                | _domNode: Node,        |                      +-----------------------+
- *  | }                          |                | _domListeners: {},     |                                   |
- *  | +------------------------+ |                | _emitterId: UID        |                                   |
- *  | | DOMEmitterMixin        | |                +--------------^---------+                                   |
- *  | +------------------------+ |                           |   |                                             |
- *  +--------------^-------------+                           |   +---------------------------------------------+
- *                 |                                         |                  click (DOM Event)
- *                 +-----------------------------------------+
- *                             fire( click, DOM Event )
+ *                                  listenTo( click, ... )
+ *                    +-----------------------------------------+
+ *                    |              stopListening( ... )       |
+ *     +----------------------------+                           |             addEventListener( click, ... )
+ *     | Host                       |                           |   +---------------------------------------------+
+ *     +----------------------------+                           |   |       removeEventListener( click, ... )     |
+ *     | _listeningTo: {            |                +----------v-------------+                                   |
+ *     |   UID: {                   |                | ProxyEmitter           |                                   |
+ *     |     emitter: ProxyEmitter, |                +------------------------+                      +------------v----------+
+ *     |     callbacks: {           |                | events: {              |                      | Node (HTMLElement)    |
+ *     |       click: [ callbacks ] |                |   click: [ callbacks ] |                      +-----------------------+
+ *     |     }                      |                | },                     |                      | data-cke-expando: UID |
+ *     |   }                        |                | _domNode: Node,        |                      +-----------------------+
+ *     | }                          |                | _domListeners: {},     |                                   |
+ *     | +------------------------+ |                | _emitterId: UID        |                                   |
+ *     | | DOMEmitterMixin        | |                +--------------^---------+                                   |
+ *     | +------------------------+ |                           |   |                                             |
+ *     +--------------^-------------+                           |   +---------------------------------------------+
+ *                    |                                         |                  click (DOM Event)
+ *                    +-----------------------------------------+
+ *                                fire( click, DOM Event )
  *
- * @singleton
- * @class DOMEmitterMixin
- * @mixins EmitterMixin
- * @implements DOMEmitter
+ * @mixin core.ui.DOMEmitterMixin
+ * @mixes core.EmitterMixin
+ * @implements core.ui.DOMEmitter
  */
-
 const DOMEmitterMixin = extend( {}, EmitterMixin, {
 	/**
 	 * Registers a callback function to be executed when an event is fired in a specific Emitter or DOM Node.
-	 * It is backwards compatible with {@link EmitterMixin#listenTo}.
+	 * It is backwards compatible with {@link core.EmitterMixin#listenTo}.
 	 *
 	 * @param {Emitter|Node} emitter The object that fires the event.
 	 * @param {String} event The name of the event.
@@ -168,6 +171,8 @@ const DOMEmitterMixin = extend( {}, EmitterMixin, {
 	 * @param {Object} [ctx] The object that represents `this` in the callback. Defaults to `emitter`.
 	 * @param {Number} [priority=10] The priority of this callback in relation to other callbacks to that same event.
 	 * Lower values are called first.
+	 *
+	 * @method core.ui.DOMEmitterMixin#listenTo
 	 */
 	listenTo() {
 		const args = Array.prototype.slice.call( arguments );
@@ -197,6 +202,8 @@ const DOMEmitterMixin = extend( {}, EmitterMixin, {
 	 * for all events from `emitter`.
 	 * @param {Function} [callback] (Requires the `event`) The function to be removed from the call list for the given
 	 * `event`.
+	 *
+	 * @method core.ui.DOMEmitterMixin#stopListening
 	 */
 	stopListening() {
 		const args = Array.prototype.slice.call( arguments );
@@ -224,6 +231,7 @@ const DOMEmitterMixin = extend( {}, EmitterMixin, {
 	 * Retrieves ProxyEmitter instance for given DOM Node residing in this Host.
 	 *
 	 * @param {Node} node DOM Node of the ProxyEmitter.
+	 * @method core.ui.DOMEmitterMixin#_getProxyEmitter
 	 * @return {ProxyEmitter} ProxyEmitter instance or null.
 	 */
 	_getProxyEmitter( node ) {

+ 2 - 4
packages/ckeditor5-ui/src/ui/model.js

@@ -12,17 +12,15 @@ import ObservableMixin from '../observablemixin.js';
 /**
  * The base MVC model class.
  *
- * @class core.ui.Model
- * @mixins core.ObservableMixin
+ * @memberOf core.ui
+ * @mixes core.ObservableMixin
  */
-
 export default class Model {
 	/**
 	 * Creates a new Model instance.
 	 *
 	 * @param {Object} [attributes] The model state attributes to be defined during the instance creation.
 	 * @param {Object} [properties] The (out of state) properties to be appended to the instance during creation.
-	 * @method constructor
 	 */
 	constructor( attributes, properties ) {
 		// Extend this instance with the additional (out of state) properties.

+ 4 - 7
packages/ckeditor5-ui/src/ui/region.js

@@ -10,36 +10,33 @@ import Collection from '../collection.js';
 /**
  * Basic Region class.
  *
- * @class Region
- * @extends Model
+ * @memberOf core.ui
  */
-
 export default class Region {
 	/**
 	 * Creates an instance of the {@link Region} class.
 	 *
 	 * @param {String} name The name of the Region.
-	 * @constructor
 	 */
 	constructor( name ) {
 		/**
 		 * The name of the region.
 		 *
-		 * @property {String}
+		 * @member {String} core.ui.Region#name
 		 */
 		this.name = name;
 
 		/**
 		 * Views which belong to the region.
 		 *
-		 * @property {Collection}
+		 * @member {Collection} core.ui.Region#views
 		 */
 		this.views = new Collection();
 
 		/**
 		 * Element of this region (see {@link #init}).
 		 *
-		 * @property {HTMLElement}
+		 * @member {HTMLElement} core.ui.Region#element
 		 */
 		this.element = null;
 	}

+ 122 - 54
packages/ckeditor5-ui/src/ui/template.js

@@ -12,29 +12,27 @@ import CKEditorError from '../ckeditorerror.js';
 /**
  * Basic Template class.
  *
- * @class Template
+ * @memberOf core.ui
  */
-
 export default class Template {
 	/**
 	 * Creates an instance of the {@link Template} class.
 	 *
-	 * @param {TemplateDefinition} definition The definition of the template.
-	 * @constructor
+	 * @param {core.ui.TemplateDefinition} def The definition of the template.
 	 */
 	constructor( def ) {
 		/**
 		 * Definition of this template.
 		 *
-		 * @property {TemplateDefinition}
+		 * @type {core.ui.TemplateDefinition}
 		 */
 		this.definition = def;
 	}
 
 	/**
-	 * Renders DOM Node using {@link #definition}.
+	 * Renders DOM Node using {@link core.ui.Template#definition}.
 	 *
-	 * See: {@link #apply}.
+	 * @see core.ui.Template#apply
 	 *
 	 * @returns {HTMLElement}
 	 */
@@ -43,11 +41,12 @@ export default class Template {
 	}
 
 	/**
-	 * Applies template {@link #def} to existing DOM tree.
+	 * Applies template {@link core.ui.Template#def} to existing DOM tree.
 	 *
 	 * **Note:** No new DOM nodes (elements, text nodes) will be created.
 	 *
-	 * See: {@link #render}, {@link View#applyTemplateToElement}.
+	 * @see core.ui.Template#render
+	 * @see View#applyTemplateToElement.
 	 *
 	 * @param {Node} element Root element for template to apply.
 	 */
@@ -68,7 +67,7 @@ export default class Template {
 	 * Renders a DOM Node from definition.
 	 *
 	 * @protected
-	 * @param {TemplateDefinition} def Definition of a Node.
+	 * @param {core.ui.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.
@@ -103,7 +102,7 @@ export default class Template {
 	 * Renders an HTMLElement from TemplateDefinition.
 	 *
 	 * @protected
-	 * @param {TemplateDefinition} def Definition of an element.
+	 * @param {core.ui.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.
@@ -139,57 +138,80 @@ export default class Template {
 	 * @returns {Text} A rendered Text.
 	 */
 	_renderText( defOrText, applyText ) {
-		const text = applyText || document.createTextNode( '' );
+		const textNode = applyText || document.createTextNode( '' );
+
+		// Check if there's a binder available for this Text Node.
+		const binder = defOrText._modelBinders && defOrText._modelBinders.text;
 
-		// Case: { text: func }, like binding
-		if ( typeof defOrText.text == 'function' ) {
-			defOrText.text( text, getTextUpdater() );
+		// 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;
 	}
 
 	/**
 	 * Renders element attributes from definition.
 	 *
 	 * @protected
-	 * @param {TemplateDefinition} def Definition of an element.
+	 * @param {core.ui.TemplateDefinition} def Definition of an element.
 	 * @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 );
 			}
 		}
 	}
 
 	/**
 	 * Recursively renders element children from definition by
-	 * calling {@link #_renderElement}.
+	 * calling {@link core.ui.Template#_renderElement}.
 	 *
 	 * @protected
-	 * @param {TemplateDefinition} def Definition of an element.
+	 * @param {core.ui.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.
 	 */
@@ -209,7 +231,7 @@ export default class Template {
 	 * Activates element `on` listeners passed in element definition.
 	 *
 	 * @protected
-	 * @param {TemplateDefinition} def Definition of an element.
+	 * @param {core.ui.TemplateDefinition} def Definition of an element.
 	 * @param {HTMLElement} el Element which is rendered.
 	 */
 	_activateElementListenerAttachers( def, el ) {
@@ -237,30 +259,53 @@ export default class Template {
 }
 
 /**
- * Returns a function which, when called in the context of HTMLElement,
- * it replaces element children with a text node of given value.
+ * 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 core.ui.View#_getModelBinder
  *
+ * @ignore
  * @private
- * @param {Function}
+ * @param {Node} node DOM Node to be modified.
+ * @returns {Object}
  */
-function getTextUpdater() {
-	return ( el, value ) => el.textContent = value;
+function getTextNodeUpdater( node ) {
+	return {
+		set( value ) {
+			node.textContent = value;
+		},
+
+		remove() {
+			node.textContent = '';
+		}
+	};
 }
 
 /**
- * Returns a function which, when called in the context of HTMLElement,
- * it updates element's attribute with given value.
+ * 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 core.ui.View#_getModelBinder
  *
+ * @ignore
  * @private
- * @param {String} attr A name of the attribute to be updated.
- * @param {Function}
+ * @param {Node} node DOM Node to be modified.
+ * @param {String} attrName Name of the attribute to be modified.
+ * @returns {Object}
  */
-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 core.ui.TemplateValueSchema}.
  *
  *		{
  *			tag: 'p',
@@ -272,31 +317,54 @@ 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 ],
  *				...
  *			}
  *		}
  *
- * @typedef TemplateDefinition
+ * @typedef core.ui.TemplateDefinition
  * @type Object
  * @property {String} tag
  * @property {Array} [children]
  * @property {Object} [attributes]
  * @property {String} [text]
  * @property {Object} [on]
+ * @property {Object} _modelBinders
+ */
+
+/**
+ * Describes a value of HTMLElement attribute or `textContent`.
+ * See: {@link core.ui.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 core.ui.TemplateValueSchema
+ * @type {Object|String|Array}
  */

+ 326 - 73
packages/ckeditor5-ui/src/ui/view.js

@@ -11,33 +11,35 @@ 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.
  *
- * @class View
- * @mixins DOMEmitterMixin
+ * @memberOf core.ui
+ * @mixes DOMEmitterMixin
  */
-
 export default class View {
 	/**
 	 * Creates an instance of the {@link View} class.
 	 *
-	 * @param {Model} model (View)Model of this View.
-	 * @constructor
+	 * @param {core.ui.Model} model (View)Model of this View.
 	 */
 	constructor( model ) {
 		/**
 		 * Model of this view.
 		 *
-		 * @property {Model}
+		 * @type {core.ui.Model}
 		 */
 		this.model = model || null;
 
 		/**
-		 * Regions of this view. See {@link #register}.
+		 * Regions of this view. See {@link core.ui.View#register}.
 		 *
-		 * @property {Collection}
+		 * @type {Collection}
 		 */
 		this.regions = new Collection( {
 			idProperty: 'name'
@@ -46,15 +48,15 @@ export default class View {
 		/**
 		 * Template of this view.
 		 *
-		 * @property {Object}
+		 * @type {Object}
 		 */
 		this.template = null;
 
 		/**
-		 * Region selectors of this view. See {@link #register}.
+		 * Region selectors of this view. See {@link core.ui.View#register}.
 		 *
 		 * @private
-		 * @property {Object}
+		 * @type {Object}
 		 */
 		this._regionsSelectors = {};
 
@@ -62,24 +64,22 @@ export default class View {
 		 * Element of this view.
 		 *
 		 * @private
-		 * @property {HTMLElement}
+		 * @type {HTMLElement}
 		 */
 		this._element = null;
 
 		/**
-		 * An instance of Template to generate {@link #_el}.
+		 * An instance of Template to generate {@link core.ui.View#_el}.
 		 *
 		 * @private
-		 * @property {Template}
+		 * @type {Template}
 		 */
 		this._template = null;
 	}
 
 	/**
 	 * Element of this view. The element is rendered on first reference
-	 * using {@link #template} definition and {@link #_template} object.
-	 *
-	 * @property element
+	 * using {@link core.ui.View#template} definition and {@link core.ui.View#_template} object.
 	 */
 	get element() {
 		if ( this._element ) {
@@ -97,7 +97,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 );
 
@@ -108,6 +111,92 @@ export default class View {
 		this._element = el;
 	}
 
+	/**
+	 * And entry point to the interface which allows binding attributes of {@link View#model}
+	 * to the DOM items like HTMLElement attributes or Text Node `textContent`, so their state
+	 * is synchronized with {@link View#model}.
+	 *
+	 * @readonly
+	 */
+	get attributeBinder() {
+		if ( this._attributeBinder ) {
+			return this._attributeBinder;
+		}
+
+		const model = this.model;
+		const binder = {
+			/**
+			 * Binds {@link View#model} to HTMLElement attribute or Text Node `textContent`
+			 * so remains in sync with the Model when it changes.
+			 *
+			 *		this.template = {
+			 *			tag: 'p',
+			 *			attributes: {
+			 *				// class="..." attribute gets bound to this.model.a
+			 *				'class': bind.to( 'a' )
+			 *			},
+			 *			children: [
+			 *				// <p>...</p> gets bound to this.model.b; always `toUpperCase()`.
+			 *				{ text: bind.to( 'b', ( value, node ) => value.toUpperCase() ) }
+			 *			]
+			 *		}
+			 *
+			 * @property {attributeBinder.to}
+			 * @param {String} attribute Name of {@link View#model} used in the binding.
+			 * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
+			 * @return {core.ui.ViewModelBinding}
+			 */
+			to( attribute, callback ) {
+				return {
+					type: bindToSymbol,
+					model: model,
+					attribute,
+					callback
+				};
+			},
+
+			/**
+			 * Binds {@link View#model} to HTMLElement attribute or Text Node `textContent`
+			 * so remains in sync with the Model when it changes. Unlike {@link View#attributeBinder.to},
+			 * it controls the presence of the attribute/`textContent` depending on the "falseness" of
+			 * {@link View#model} attribute.
+			 *
+			 *		this.template = {
+			 *			tag: 'input',
+			 *			attributes: {
+			 *				// <input checked> this.model.a is not undefined/null/false/''
+			 *				// <input> this.model.a is undefined/null/false
+			 *				checked: bind.if( 'a' )
+			 *			},
+			 *			children: [
+			 *				{
+			 *					// <input>"b-is-not-set"</input> when this.model.b is undefined/null/false/''
+			 *					// <input></input> when this.model.b is not "falsy"
+			 *					text: bind.if( 'b', 'b-is-not-set', ( value, node ) => !value )
+			 *				}
+			 *			]
+			 *		}
+			 *
+			 * @property {attributeBinder.if}
+			 * @param {String} attribute Name of {@link View#model} used in the binding.
+			 * @param {String} [valueIfTrue] Value set when {@link View#model} attribute is not undefined/null/false/''.
+			 * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
+			 * @return {core.ui.ViewModelBinding}
+			 */
+			if( attribute, valueIfTrue, callback ) {
+				return {
+					type: bindIfSymbol,
+					model: model,
+					attribute,
+					valueIfTrue,
+					callback
+				};
+			}
+		};
+
+		return ( this._attributeBinder = binder );
+	}
+
 	/**
 	 * Initializes the view.
 	 */
@@ -116,7 +205,7 @@ export default class View {
 	}
 
 	/**
-	 * Registers a region in {@link #regions}.
+	 * Registers a region in {@link core.ui.View#regions}.
 	 *
 	 *		let view = new View();
 	 *
@@ -135,7 +224,7 @@ export default class View {
 	 * @param {String|Region} stringOrRegion The name or an instance of the Region
 	 * to be registered. If `String`, the region will be created on the fly.
 	 * @param {String|Function|true} regionSelector The selector to retrieve region's element
-	 * in DOM when the region instance is initialized (see {@link Region#init}, {@link #init}).
+	 * in DOM when the region instance is initialized (see {@link Region#init}, {@link core.ui.View#init}).
 	 * @param {Boolean} [override] When set `true` it will allow overriding of registered regions.
 	 */
 	register( ...args ) {
@@ -190,50 +279,6 @@ export default class View {
 		this._regionsSelectors[ regionName ] = regionSelector;
 	}
 
-	/**
-	 * 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.
-	 *
-	 * @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.
-	 */
-	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;
-
-			if ( callback ) {
-				onModelChange = ( evt, value ) => {
-					let processedValue = callback( el, value );
-
-					if ( typeof processedValue != 'undefined' ) {
-						domUpdater( el, processedValue );
-					}
-				};
-			} 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 ] );
-		};
-	}
-
 	/**
 	 * Applies template to existing DOM element in the context of a View.
 	 *
@@ -258,10 +303,14 @@ export default class View {
 	 * See: {@link Template#apply}.
 	 *
 	 * @param {DOMElement} element DOM Element to initialize.
-	 * @param {TemplateDefinition} def Template definition to be applied.
+	 * @param {core.ui.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 );
 	}
@@ -269,8 +318,8 @@ export default class View {
 	/**
 	 * Destroys the view instance. The process includes:
 	 *
-	 * 1. Removal of child views from {@link #regions}.
-	 * 2. Destruction of the {@link #regions}.
+	 * 1. Removal of child views from {@link core.ui.View#regions}.
+	 * 2. Destruction of the {@link core.ui.View#regions}.
 	 * 3. Removal of {#link #_el} from DOM.
 	 */
 	destroy() {
@@ -294,8 +343,8 @@ export default class View {
 	}
 
 	/**
-	 * Initializes {@link #regions} of this view by passing a DOM element
-	 * generated from {@link #_regionsSelectors} into {@link Region#init}.
+	 * Initializes {@link core.ui.View#regions} of this view by passing a DOM element
+	 * generated from {@link core.ui.View#_regionsSelectors} into {@link Region#init}.
 	 *
 	 * @protected
 	 */
@@ -368,15 +417,153 @@ export default class View {
 		};
 	}
 
+	/**
+	 * For given {@link core.ui.TemplateValueSchema} found by (@link _extendTemplateWithModelBinders} containing
+	 * {@link core.ui.ViewModelBinding} it returns a function, which when called by {@link Template#render}
+	 * or {@link Template#apply} activates the binding and sets its initial value.
+	 *
+	 * Note: {@link core.ui.TemplateValueSchema} can be for HTMLElement attributes or Text Node `textContent`.
+	 *
+	 * @protected
+	 * @param {core.ui.TemplateValueSchema}
+	 * @return {Function}
+	 */
+	_getModelBinder( valueSchema ) {
+		valueSchema = normalizeBinderValueSchema( valueSchema );
+
+		/**
+		 * Assembles the value using {@link core.ui.TemplateValueSchema} and stores it in a form of
+		 * an Array. Each entry of an Array corresponds to one of {@link core.ui.TemplateValueSchema}
+		 * items.
+		 *
+		 * @private
+		 * @param {Node} node
+		 * @return {Array}
+		 */
+		const getBoundValue = ( node ) => {
+			let model, modelValue;
+
+			return valueSchema.map( schemaItem => {
+				model = schemaItem.model;
+
+				if ( model ) {
+					modelValue = model[ schemaItem.attribute ];
+
+					if ( schemaItem.callback ) {
+						modelValue = schemaItem.callback( modelValue, node );
+					}
+
+					return modelValue;
+				} else {
+					return schemaItem;
+				}
+			} );
+		};
+
+		/**
+		 * Attaches a listener to {@link View#model}, which updates DOM with a value constructed from
+		 * {@link core.ui.TemplateValueSchema} when {@link View#model} attribute value changes.
+		 *
+		 * This function is called by {@link Template#render} or {@link Template#apply}.
+		 *
+		 * @param {Node} node DOM Node to be updated when {@link View#model} changes.
+		 * @param {Function} domUpdater A function provided by {@link Template} which updates corresponding
+		 * DOM attribute or `textContent`.
+		 */
+		return ( node, domUpdater ) => {
+			// Check if valueSchema is a single bind.if, like:
+			//		{ class: bind.if( 'foo' ) }
+			const isPlainBindIf = valueSchema.length == 1 && valueSchema[ 0 ].type == bindIfSymbol;
+
+			// A function executed each time bound model attribute changes.
+			const onModelChange = () => {
+				let value = getBoundValue( node );
+
+				if ( isPlainBindIf ) {
+					value = value[ 0 ];
+				} else {
+					value = value.reduce( binderValueReducer, '' );
+				}
+
+				const isSet = isPlainBindIf ? !!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 core.ui.ViewModelBinding} created by {@link core.ui.View#attributeBinder}.
+	 * Then, for each such a binding, it creates corresponding entry in {@link Template#_modelBinders},
+	 * which can be then activated by {@link Template#render} or {@link Template#apply}.
+	 *
+	 * @protected
+	 * @param {core.ui.TemplateDefinition} def
+	 */
+	_extendTemplateWithModelBinders( def ) {
+		const attributes = def.attributes;
+		const text = def.text;
+		let binders = def._modelBinders;
+		let attrName, attrValue;
+
+		if ( !binders && isPlainObject( def ) ) {
+			Object.defineProperty( def, '_modelBinders', {
+				enumerable: false,
+				writable: true,
+				value: {
+					attributes: {}
+				}
+			} );
+
+			binders = def._modelBinders;
+		}
+
+		if ( attributes ) {
+			for ( attrName in attributes ) {
+				attrValue = attributes[ attrName ];
+
+				if ( hasModelBinding( attrValue ) ) {
+					binders.attributes[ attrName ] = this._getModelBinder( attrValue );
+				}
+			}
+		}
+
+		if ( text && hasModelBinding( text ) ) {
+			binders.text = this._getModelBinder( text );
+		}
+
+		// Repeat recursively for the children.
+		if ( def.children ) {
+			def.children.forEach( this._extendTemplateWithModelBinders, this );
+		}
+	}
+
 	/**
 	 * Iterates over "on" property in {@link TemplateDefinition} to recursively
 	 * replace each listener declaration with a function which, once executed in a context
 	 * of an element, attaches native DOM listener to that element.
 	 *
 	 * @protected
-	 * @param {TemplateDefinition} def Template definition.
+	 * @param {core.ui.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 +612,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 );
 		}
 	}
 }
@@ -437,6 +624,7 @@ const validSelectorTypes = new Set( [ 'string', 'boolean', 'function' ] );
 /**
  * Check whether region selector is valid.
  *
+ * @ignore
  * @private
  * @param {*} selector Selector to be checked.
  * @returns {Boolean}
@@ -444,3 +632,68 @@ const validSelectorTypes = new Set( [ 'string', 'boolean', 'function' ] );
 function isValidRegionSelector( selector ) {
 	return validSelectorTypes.has( typeof selector ) && selector !== false;
 }
+
+/**
+ * Normalizes given {@link core.ui.TemplateValueSchema} it's always in an Array–like format:
+ *
+ * 		{ attributeName/text: 'bar' } ->
+ * 			{ attributeName/text: [ 'bar' ] }
+ *
+ * 		{ attributeName/text: { model: ..., modelAttributeName: ..., callback: ... } } ->
+ * 			{ attributeName/text: [ { model: ..., modelAttributeName: ..., callback: ... } ] }
+ *
+ * 		{ attributeName/text: [ 'bar', { model: ..., modelAttributeName: ... }, 'baz' ] }
+ *
+ * @ignore
+ * @private
+ * @param {core.ui.TemplateValueSchema} valueSchema
+ * @returns {Array}
+ */
+function normalizeBinderValueSchema( valueSchema ) {
+	return Array.isArray( valueSchema ) ? valueSchema : [ valueSchema ];
+}
+
+/**
+ * Checks whether given {@link core.ui.TemplateValueSchema} contains a
+ * {@link core.ui.ViewModelBinding}.
+ *
+ * @ignore
+ * @private
+ * @param {core.ui.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.
+ *
+ * @ignore
+ * @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 core.ui.ViewModelBinding
+ * @type Object
+ * @property {Symbol} type
+ * @property {core.ui.Model} model
+ * @property {String} attribute
+ * @property {String} [valueIfTrue]
+ * @property {Function} [callback]
+ */

+ 19 - 5
packages/ckeditor5-ui/src/utils.js

@@ -8,12 +8,8 @@
 import isPlainObject from './lib/lodash/isPlainObject.js';
 
 /**
- * An index at which arrays differ. If arrays are same at all indexes, it represents how arrays are related.
- * In this case, possible values are: 'SAME', 'PREFIX' or 'EXTENSION'.
- *
- * @typedef {String|Number} utils.ArrayRelation
+ * @namespace core.utils
  */
-
 const utils = {
 	/**
 	 * Creates a spy function (ala Sinon.js) that can be used to inspect call to it.
@@ -22,6 +18,7 @@ const utils = {
 	 *
 	 *  * spy.called: property set to `true` if the function has been called at least once.
 	 *
+	 * @memberOf core.utils
 	 * @returns {Function} The spy function.
 	 */
 	spy() {
@@ -34,6 +31,8 @@ const utils = {
 	 * Returns a unique id. This id is a number (starting from 1) which will never get repeated on successive calls
 	 * to this method.
 	 *
+	 * @function
+	 * @memberOf core.utils
 	 * @returns {Number} A number representing the id.
 	 */
 	uid: ( () => {
@@ -47,6 +46,7 @@ const utils = {
 	/**
 	 * Checks if value implements iterator interface.
 	 *
+	 * @memberOf core.utils
 	 * @param {*} value The value to check.
 	 * @returns {Boolean} True if value implements iterator interface.
 	 */
@@ -66,6 +66,7 @@ const utils = {
 	 *   compareArrays( [ 0, 2 ], [ 1, 2 ] ); // 0
 	 *   compareArrays( [ 0, 2 ], [ 0, 1 ] ); // 1
 	 *
+	 * @memberOf core.utils
 	 * @param {Array} a Array that is compared.
 	 * @param {Array} b Array to compare with.
 	 * @returns {utils.ArrayRelation} How array `a` is related to `b`.
@@ -99,6 +100,7 @@ const utils = {
 	 *		const map = utils.objectToMap( { 'foo': 1, 'bar': 2 } );
 	 *		map.get( 'foo' ); // 1
 	 *
+	 * @memberOf core.utils
 	 * @param {Object} obj Object to transform.
 	 * @returns {Map} Map created from object.
 	 */
@@ -119,6 +121,7 @@ const utils = {
 	 *		map = utils.toMap( [ [ 'foo', 1 ], [ 'bar', 2 ] ] );
 	 *		map = utils.toMap( anotherMap );
 	 *
+	 * @memberOf core.utils
 	 * @param {Object|Iterable} data Object or iterable to transform.
 	 * @returns {Map} Map created from data.
 	 */
@@ -133,6 +136,7 @@ const utils = {
 	/**
 	 * Checks whether given {Map}s are equal, that is has same size and same key-value pairs.
 	 *
+	 * @memberOf core.utils
 	 * @returns {Boolean} `true` if given maps are equal, `false` otherwise.
 	 */
 	mapsEqual( mapA, mapB ) {
@@ -155,6 +159,7 @@ const utils = {
 	/**
 	 * Returns `nth` (starts from `0` of course) item of an `iterable`.
 	 *
+	 * @memberOf core.utils
 	 * @param {Number} index
 	 * @param {Iterable.<*>} iterable
 	 * @returns {*}
@@ -190,6 +195,7 @@ const utils = {
 	 *
 	 * Note: Properties which already exist in the base class will not be overriden.
 	 *
+	 * @memberOf core.utils
 	 * @param {Function} [baseClass] Class which prototype will be extended.
 	 * @param {Object} [...mixins] Objects from which to get properties.
 	 */
@@ -210,4 +216,12 @@ const utils = {
 	}
 };
 
+/**
+ * An index at which arrays differ. If arrays are same at all indexes, it represents how arrays are related.
+ * In this case, possible values are: 'SAME', 'PREFIX' or 'EXTENSION'.
+ *
+ * @memberOf core.utils
+ * @typedef {String|Number} ArrayRelation
+ */
+
 export default utils;

+ 119 - 24
packages/ckeditor5-ui/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,71 @@ 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 );
+			sinon.assert.calledWithExactly( spy1, el.firstChild, sinon.match.object );
+			sinon.assert.calledWithExactly( spy2, el.lastChild.firstChild, sinon.match.object );
+		} );
+
+		it( 'uses DOM updater – attributes', () => {
+			const spy = testUtils.sinon.spy();
+			const el = new Template( {
+				tag: 'p',
+				attributes: {
+					'class': {}
+				},
+				_modelBinders: {
+					attributes: {
+						class: spy
+					}
+				}
+			} ).render();
 
-			spy2.firstCall.args[ 1 ]( el.lastChild.firstChild, 'bar' );
-			expect( el.outerHTML ).to.be.equal( '<p><span>bar</span></p>' );
+			// Check whether DOM updater is correct.
+			spy.firstCall.args[ 1 ].set( 'x' );
+			expect( el.outerHTML ).to.be.equal( '<p class="x"></p>' );
 
-			spy1.firstCall.args[ 1 ]( el.firstChild, 'foo' );
-			expect( el.outerHTML ).to.be.equal( '<p>foo<span>bar</span></p>' );
+			spy.firstCall.args[ 1 ].remove();
+			expect( el.outerHTML ).to.be.equal( '<p></p>' );
+		} );
+
+		it( 'uses DOM updater – text', () => {
+			const spy = testUtils.sinon.spy();
+			const el = new Template( {
+				tag: 'p',
+				children: [
+					{
+						text: {},
+						_modelBinders: {
+							text: spy
+						}
+					}
+				],
+			} ).render();
+
+			// Check whether DOM updater is correct.
+			spy.firstCall.args[ 1 ].set( 'x' );
+			expect( el.outerHTML ).to.be.equal( '<p>x</p>' );
+
+			spy.firstCall.args[ 1 ].remove();
+			expect( el.outerHTML ).to.be.equal( '<p></p>' );
 		} );
 	} );
 
@@ -401,11 +486,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 +508,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 );
 		} );
 	} );
 } );

+ 406 - 91
packages/ckeditor5-ui/tests/ui/view.js

@@ -219,116 +219,427 @@ 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 = 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 – value processing (use Node)', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+					const callback = ( value, node ) => {
+						return ( !!node.tagName && value > 0 ) ? 'HTMLElement positive' : '';
+					};
+
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo', callback )
+						},
+						children: [
+							{
+								text: bind.to( 'foo', callback )
+							}
+						]
+					};
+				} );
+
+				setTestViewInstance( { foo: 3 } );
+				expect( view.element.outerHTML ).to.be.equal( '<p class="HTMLElement positive"></p>' );
+
+				view.model.foo = -7;
+				expect( view.element.outerHTML ).to.be.equal( '<p></p>' );
+			} );
+
+			it( 'allows binding attribute to the model – custom callback', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.to( 'foo', ( value, el ) => {
+								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', 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', 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;
+
+					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>' );
+			} );
 
-							if ( value == 'changed' ) {
-								return value;
+			// 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>' );
+			} );
+
+			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' ]
+					};
+				} );
+
+				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', 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 – value of an attribute processed by a callback (use Node)', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
+
+					return {
+						tag: 'p',
+						attributes: {
+							'class': bind.if( 'foo', 'eqls-tag-name', ( value, el ) => el.tagName === value )
+						},
+						children: [ 'abc' ]
+					};
+				} );
+
+				setTestViewInstance( { foo: 'bar' } );
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</p>' );
+
+				view.model.foo = 'P';
+				expect( view.element.outerHTML ).to.be.equal( '<p class="eqls-tag-name">abc</p>' );
+
+				view.model.foo = 64;
+				expect( view.element.outerHTML ).to.be.equal( '<p>abc</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 – falsy values', () => {
+				setTestViewClass( function() {
+					const bind = this.attributeBinder;
 
-			view.model.foo = 'changed';
-			expect( view.element.outerHTML ).to.be.equal( '<p class="changed">changed</p>' );
+					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>abc</p>' );
+			} );
 		} );
 	} );
 
@@ -596,10 +907,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 +950,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 +1007,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 +1018,7 @@ describe( 'View', () => {
 							keyup: spy2
 						},
 						attributes: {
-							class: view.bindToAttribute( 'b', ( el, b ) => 'applied-A-' + b ),
+							class: bind.to( 'b', b => 'applied-A-' + b ),
 							id: 'applied-A'
 						},
 						children: [ 'Text applied to childA.' ]
@@ -714,7 +1029,7 @@ describe( 'View', () => {
 							keydown: spy3
 						},
 						attributes: {
-							class: view.bindToAttribute( 'b', ( el, b ) => 'applied-B-' + b ),
+							class: bind.to( 'b', b => 'applied-B-' + b ),
 							id: 'applied-B'
 						},
 						children: [ 'Text applied to childB.' ]
@@ -725,8 +1040,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', a => a.toUpperCase() ),
+					class: bind.to( 'b', b => 'applied-parent-' + b )
 				}
 			} );
 
@@ -788,7 +1103,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();