浏览代码

Merge pull request #156 from ckeditor/t/152

Feature: Implemented features necessary for creating inline editors UI – `FloatingPanelView` class, `Template.revert()` method and `enableToolbarKeyboardFocus()` util. Closes #152.

BREAKING CHANGE: The `ui/balloonpanel/balloonpanelview` module was renamed to `ui/panel/balloon/balloonpanelview`. See #152.
Piotrek Koszuliński 8 年之前
父节点
当前提交
52f54b6831
共有 25 个文件被更改,包括 1719 次插入237 次删除
  1. 2 2
      packages/ckeditor5-ui/src/dropdown/dropdownview.js
  2. 13 3
      packages/ckeditor5-ui/src/editableui/editableuiview.js
  3. 11 11
      packages/ckeditor5-ui/src/focuscycler.js
  4. 2 2
      packages/ckeditor5-ui/src/list/listview.js
  5. 11 10
      packages/ckeditor5-ui/src/balloonpanel/balloonpanelview.js
  6. 196 0
      packages/ckeditor5-ui/src/panel/floating/floatingpanelview.js
  7. 431 149
      packages/ckeditor5-ui/src/template.js
  8. 48 0
      packages/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus.js
  9. 2 2
      packages/ckeditor5-ui/src/toolbar/toolbarview.js
  10. 0 4
      packages/ckeditor5-ui/src/view.js
  11. 47 8
      packages/ckeditor5-ui/tests/editableui/editableuiview.js
  12. 1 1
      packages/ckeditor5-ui/tests/manual/contextualtoolbar/contextualtoolbar.js
  13. 1 1
      packages/ckeditor5-ui/tests/manual/imagetoolbar/imagetoolbar.js
  14. 1 1
      packages/ckeditor5-ui/tests/manual/tickets/126/1.js
  15. 3 3
      packages/ckeditor5-ui/tests/balloonpanel/balloonpanelview.js
  16. 229 0
      packages/ckeditor5-ui/tests/panel/floating/floatingpanelview.js
  17. 582 27
      packages/ckeditor5-ui/tests/template.js
  18. 98 0
      packages/ckeditor5-ui/tests/toolbar/enabletoolbarkeyboardfocus.js
  19. 4 4
      packages/ckeditor5-ui/tests/view.js
  20. 0 0
      packages/ckeditor5-ui/theme/components/panel/balloonpanel.scss
  21. 11 0
      packages/ckeditor5-ui/theme/components/panel/floatingpanel.scss
  22. 8 6
      packages/ckeditor5-ui/theme/components/stickytoolbar.scss
  23. 14 0
      packages/ckeditor5-ui/theme/components/toolbar/stickytoolbar.scss
  24. 0 0
      packages/ckeditor5-ui/theme/components/toolbar/toolbar.scss
  25. 4 3
      packages/ckeditor5-ui/theme/theme.scss

+ 2 - 2
packages/ckeditor5-ui/src/dropdown/dropdownview.js

@@ -69,10 +69,10 @@ export default class DropdownView extends View {
 		this.focusTracker = new FocusTracker();
 
 		/**
-		 * Instance of the {@link module:core/keystrokehandler~KeystrokeHandler}.
+		 * Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
 		 *
 		 * @readonly
-		 * @member {module:core/keystrokehandler~KeystrokeHandler}
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
 		 */
 		this.keystrokes = new KeystrokeHandler();
 

+ 13 - 3
packages/ckeditor5-ui/src/editableui/editableuiview.js

@@ -60,6 +60,14 @@ export default class EditableUIView extends View {
 		this.set( 'isFocused', false );
 
 		/**
+		 * An external {@link #editableElement} passed into the constructor, which also means
+		 * the view will not render its {@link #template}.
+		 *
+		 * @member {HTMLElement} #externalElement
+		 */
+		this.externalElement = editableElement;
+
+		/**
 		 * The element which is the main editable element (usually the one with `contentEditable="true"`).
 		 *
 		 * @readonly
@@ -74,8 +82,8 @@ export default class EditableUIView extends View {
 	 * @returns {Promise}
 	 */
 	init() {
-		if ( this.editableElement ) {
-			this.template.apply( this.editableElement );
+		if ( this.externalElement ) {
+			this.template.apply( this.externalElement );
 		} else {
 			this.editableElement = this.element;
 		}
@@ -87,7 +95,9 @@ export default class EditableUIView extends View {
 	 * @inheritDoc
 	 */
 	destroy() {
-		this.editableElement.contentEditable = false;
+		if ( this.externalElement ) {
+			this.template.revert( this.externalElement );
+		}
 
 		return super.destroy();
 	}

+ 11 - 11
packages/ckeditor5-ui/src/focuscycler.js

@@ -52,7 +52,7 @@ export default class FocusCycler {
 	 * @param {Object} options Configuration options.
 	 * @param {module:utils/collection~Collection|Object} options.focusables
 	 * @param {module:utils/focustracker~FocusTracker} options.focusTracker
-	 * @param {module:core/keystrokehandler~KeystrokeHandler} [options.keystrokeHandler]
+	 * @param {module:utils/keystrokehandler~KeystrokeHandler} [options.keystrokeHandler]
 	 * @param {Object} [options.actions]
 	 */
 	constructor( options ) {
@@ -67,17 +67,17 @@ export default class FocusCycler {
 
 		/**
 		 * A focus tracker instance that cycler uses to determine focus
-		 * state in {@link #viewCollection}.
+		 * state in {@link #focusables}.
 		 *
 		 * @readonly
 		 * @member {module:utils/focustracker~FocusTracker} #focusTracker
 		 */
 
 		/**
-		 * Instance of the {@link module:core/keystrokehandler~KeystrokeHandler}.
+		 * Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
 		 *
 		 * @readonly
-		 * @member {module:core/keystrokehandler~KeystrokeHandler} #keystrokeHandler
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler} #keystrokeHandler
 		 */
 
 		/**
@@ -117,7 +117,7 @@ export default class FocusCycler {
 	}
 
 	/**
-	 * Returns the first focusable view in {@link #viewCollection}.
+	 * Returns the first focusable view in {@link #focusables}.
 	 * `null` if there's none.
 	 *
 	 * @readonly
@@ -128,7 +128,7 @@ export default class FocusCycler {
 	}
 
 	/**
-	 * Returns the last focusable view in {@link #viewCollection}.
+	 * Returns the last focusable view in {@link #focusables}.
 	 * `null` if there's none.
 	 *
 	 * @readonly
@@ -139,7 +139,7 @@ export default class FocusCycler {
 	}
 
 	/**
-	 * Returns the next focusable view in {@link #viewCollection} based on {@link #current}.
+	 * Returns the next focusable view in {@link #focusables} based on {@link #current}.
 	 * `null` if there's none.
 	 *
 	 * @readonly
@@ -150,7 +150,7 @@ export default class FocusCycler {
 	}
 
 	/**
-	 * Returns the previous focusable view in {@link #viewCollection} based on {@link #current}.
+	 * Returns the previous focusable view in {@link #focusables} based on {@link #current}.
 	 * `null` if there's none.
 	 *
 	 * @readonly
@@ -161,7 +161,7 @@ export default class FocusCycler {
 	}
 
 	/**
-	 * An index of the view in the {@link #viewCollection} which is focused according
+	 * An index of the view in the {@link #focusables} which is focused according
 	 * to {@link #focusTracker}. `null` when there's no such view.
 	 *
 	 * @readonly
@@ -170,7 +170,7 @@ export default class FocusCycler {
 	get current() {
 		let index = null;
 
-		// There's no focused view in the viewCollection.
+		// There's no focused view in the focusables.
 		if ( this.focusTracker.focusedElement === null ) {
 			return null;
 		}
@@ -229,7 +229,7 @@ export default class FocusCycler {
 	}
 
 	/**
-	 * Returns the next/previous focusable view in {@link #viewCollection} with respect
+	 * Returns the next/previous focusable view in {@link #focusables} with respect
 	 * to {@link #current}.
 	 *
 	 * @protected

+ 2 - 2
packages/ckeditor5-ui/src/list/listview.js

@@ -42,10 +42,10 @@ export default class ListView extends View {
 		this.focusTracker = new FocusTracker();
 
 		/**
-		 * Instance of the {@link module:core/keystrokehandler~KeystrokeHandler}.
+		 * Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
 		 *
 		 * @readonly
-		 * @member {module:core/keystrokehandler~KeystrokeHandler}
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
 		 */
 		this.keystrokes = new KeystrokeHandler();
 

+ 11 - 10
packages/ckeditor5-ui/src/balloonpanel/balloonpanelview.js

@@ -4,13 +4,13 @@
  */
 
 /**
- * @module ui/balloonpanel/balloonpanelview
+ * @module ui/panel/balloon/balloonpanelview
  */
 
 /* globals document */
 
-import View from '../view';
-import Template from '../template';
+import View from '../../view';
+import Template from '../../template';
 import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position';
 import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit';
 
@@ -54,7 +54,8 @@ export default class BalloonPanelView extends View {
 		 * controls the minor aspects of the balloon's visual appearance like placement
 		 * of the "arrow". To support a new position, an additional CSS must be created.
 		 *
-		 * Default position names correspond with {@link #defaultPositions}.
+		 * Default position names correspond with
+		 * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions}.
 		 *
 		 * @observable
 		 * @default 'se'
@@ -136,7 +137,7 @@ export default class BalloonPanelView extends View {
 	 *
 	 * @param {module:utils/dom/position~Options} options Positioning options compatible with
 	 * {@link module:utils/dom/position~getOptimalPosition}. Default `positions` array is
-	 * {@link module:ui/balloonpanel/balloonpanelview~BalloonPanelView.defaultPositions}.
+	 * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions}.
 	 */
 	attachTo( options ) {
 		this.show();
@@ -174,7 +175,7 @@ export default class BalloonPanelView extends View {
  *	    >|-----|<---------------- horizontal offset
  *
  * @default 30
- * @member {Number} module:ui/balloonpanel/balloonpanelview~BalloonPanelView.arrowHorizontalOffset
+ * @member {Number} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowHorizontalOffset
  */
 BalloonPanelView.arrowHorizontalOffset = 30;
 
@@ -193,13 +194,13 @@ BalloonPanelView.arrowHorizontalOffset = 30;
  *		                       ^
  *
  * @default 15
- * @member {Number} module:ui/balloonpanel/balloonpanelview~BalloonPanelView.arrowVerticalOffset
+ * @member {Number} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowVerticalOffset
  */
 BalloonPanelView.arrowVerticalOffset = 15;
 
 /**
  * A default set of positioning functions used by the balloon panel view
- * when attaching using {@link #attachTo} method.
+ * when attaching using {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView#attachTo} method.
  *
  * The available positioning functions are as follows:
  *
@@ -238,14 +239,14 @@ BalloonPanelView.arrowVerticalOffset = 15;
  *		              V
  *		         [ Target ]
  *
- * See {@link #attachTo}.
+ * See {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView#attachTo}.
  *
  * Positioning functions must be compatible with {@link module:utils/dom/position~Position}.
  *
  * The name that position function returns will be reflected in balloon panel's class that
  * controls the placement of the "arrow". See {@link #position} to learn more.
  *
- * @member {Object} module:ui/balloonpanel/balloonpanelview~BalloonPanelView.defaultPositions
+ * @member {Object} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions
  */
 BalloonPanelView.defaultPositions = {
 	se: ( targetRect ) => ( {

+ 196 - 0
packages/ckeditor5-ui/src/panel/floating/floatingpanelview.js

@@ -0,0 +1,196 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/panel/floating/floatingpanelview
+ */
+
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import Template from '../../template';
+import View from '../../view';
+import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit';
+import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position';
+
+const toPx = toUnit( 'px' );
+
+/**
+ * The floating panel view class. It floats around the
+ * {@link module:ui/panel/floating/floatingpanelview~FloatingPanelView#targetElement} in DOM
+ * to remain visible in the browser viewport.
+ *
+ * See {@link module:ui/panel/floating/floatingpanelview~FloatingPanelView.defaultPositions}
+ * to learn about the positioning.
+ *
+ * @extends module:ui/view~View
+ */
+export default class FloatingPanelView extends View {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		const bind = this.bindTemplate;
+
+		/**
+		 * Controls whether the floating panel is active. When any editable
+		 * is focused in the editor, panel becomes active.
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {Boolean} #isActive
+		 */
+		this.set( 'isActive', false );
+
+		/**
+		 * The absolute top position of the panel, in pixels.
+		 *
+		 * @observable
+		 * @default 0
+		 * @member {Number} #top
+		 */
+		this.set( 'top', 0 );
+
+		/**
+		 * The absolute left position of the panel, in pixels.
+		 *
+		 * @observable
+		 * @default 0
+		 * @member {Number} #left
+		 */
+		this.set( 'left', 0 );
+
+		/**
+		 * An element with respect to which the panel is positioned.
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {HTMLElement} #targetElement
+		 */
+		this.set( 'targetElement', null );
+
+		/**
+		 * Collection of the child views which creates balloon panel contents.
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.content = this.createCollection();
+
+		this.template = new Template( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck-floating-panel',
+					bind.if( 'isActive', 'ck-floating-panel_active' ),
+				],
+				style: {
+					top: bind.to( 'top', toPx ),
+					left: bind.to( 'left', toPx ),
+				}
+			},
+
+			children: this.content
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		this.listenTo( global.window, 'scroll', () => this._updatePosition() );
+		this.listenTo( this, 'change:isActive', () => this._updatePosition() );
+
+		return super.init();
+	}
+
+	/**
+	 * Analyzes the environment to decide where the panel should
+	 * be positioned.
+	 *
+	 * @protected
+	 */
+	_updatePosition() {
+		if ( !this.isActive ) {
+			return;
+		}
+
+		const { nw, sw, ne, se } = FloatingPanelView.defaultPositions;
+		const { top, left } = getOptimalPosition( {
+			element: this.element,
+			target: this.targetElement,
+			positions: [ nw, sw, ne, se ],
+			limiter: global.document.body,
+			fitInViewport: true
+		} );
+
+		Object.assign( this, { top, left } );
+	}
+}
+
+/**
+ * A default set of positioning functions used by the panel view to float
+ * around {@link module:ui/panel/floating/floatingpanelview~FloatingPanelView#targetElement}.
+ *
+ * The available positioning functions are as follows:
+ *
+ * * South east:
+ *
+ *		+----------------+
+ *		| #targetElement |
+ *		+----------------+
+ *		         [ Panel ]
+ *
+ * * South west:
+ *
+ *		+----------------+
+ *		| #targetElement |
+ *		+----------------+
+ *		[ Panel ]
+ *
+ * * North east:
+ *
+ *		         [ Panel ]
+ *		+----------------+
+ *		| #targetElement |
+ *		+----------------+
+ *
+ *
+ * * North west:
+ *
+ *		[ Panel ]
+ *		+----------------+
+ *		| #targetElement |
+ *		+----------------+
+ *
+ * Positioning functions must be compatible with {@link module:utils/dom/position~Position}.
+ *
+ * @member {Object} module:ui/panel/floating/floatingpanelview~FloatingPanelView.defaultPositions
+ */
+FloatingPanelView.defaultPositions = {
+	nw: ( targetRect, panelRect ) => ( {
+		top: targetRect.top - panelRect.height,
+		left: targetRect.left,
+		name: 'nw'
+	} ),
+
+	sw: ( targetRect ) => ( {
+		top: targetRect.bottom,
+		left: targetRect.left,
+		name: 'sw'
+	} ),
+
+	ne: ( targetRect, panelRect ) => ( {
+		top: targetRect.top - panelRect.height,
+		left: targetRect.left + targetRect.width - panelRect.width,
+		name: 'ne'
+	} ),
+
+	se: ( targetRect, panelRect ) => ( {
+		top: targetRect.bottom,
+		left: targetRect.left + targetRect.width - panelRect.width,
+		name: 'se'
+	} )
+};

+ 431 - 149
packages/ckeditor5-ui/src/template.js

@@ -102,6 +102,18 @@ export default class Template {
 		 *
 		 * @member {Object} #eventListeners
 		 */
+
+		/**
+		 * Data used by {@link #revert} method to restore a node
+		 * to its original state.
+		 *
+		 * See: {@link #apply}.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/template~RenderData}
+		 */
+		this._revertData = null;
 	}
 
 	/**
@@ -112,7 +124,9 @@ export default class Template {
 	 * @returns {HTMLElement|Text}
 	 */
 	render() {
-		const node = this._renderNode( undefined, true );
+		const node = this._renderNode( {
+			intoFragment: true
+		} );
 
 		this._isRendered = true;
 
@@ -125,6 +139,12 @@ export default class Template {
 	 * **Note:** No new DOM nodes (HTMLElement or Text) will be created. Applying extends attributes
 	 * ({@link module:ui/template~TemplateDefinition attributes}) and listeners ({@link module:ui/template~TemplateDefinition on}) only.
 	 *
+	 * **Note:** Existing "class" and "style" attributes are extended when a template
+	 * is applied to a Node, while other attributes and `textContent` are overridden.
+	 *
+	 * **Note:** The process of applying a template can be easily reverted using
+	 * {@link module:ui/template~Template#revert} method.
+	 *
 	 *		const element = document.createElement( 'div' );
 	 *		const bind = Template.bind( observableInstance, emitterInstance );
 	 *
@@ -144,19 +164,38 @@ export default class Template {
 	 *		element.outerHTML == "<div id="first-div" class="my-div">Div text.</div>"
 	 *
 	 * @see module:ui/template~Template#render
-	 * @param {Node} element Root element for the template to apply.
+	 * @see module:ui/template~Template#revert
+	 * @param {Node} node Root node for the template to apply.
 	 */
 	apply( node ) {
-		if ( !node ) {
+		this._revertData = getEmptyRevertData();
+
+		this._renderNode( {
+			node: node,
+			isApplying: true,
+			revertData: this._revertData
+		} );
+
+		return node;
+	}
+
+	/**
+	 * Reverts a template {@link module:ui/template~Template#apply applied} to a DOM Node.
+	 *
+	 * @param {Node} node Root node for the template to revert. In most cases, it's the same node
+	 * that {@link module:ui/template~Template#apply} has used.
+	 */
+	revert( node ) {
+		if ( !this._revertData ) {
 			/**
-			 * No DOM Node specified.
+			 * Attempting reverting a template which has not been applied yet.
 			 *
-			 * @error ui-template-wrong-syntax
+			 * @error ui-template-revert-not-applied
 			 */
-			throw new CKEditorError( 'ui-template-wrong-node: No DOM Node specified.' );
+			throw new CKEditorError( 'ui-template-revert-not-applied: Attempting reverting a template which has not been applied yet.' );
 		}
 
-		return this._renderNode( node );
+		this._revertTemplateFromNode( node, this._revertData );
 	}
 
 	/**
@@ -265,14 +304,12 @@ export default class Template {
 	 * Renders a DOM Node (either `HTMLElement` or `Text`) out of the template.
 	 *
 	 * @protected
-	 * @param {Node} applyNode If specified, this template will be applied to an existing DOM Node.
-	 * @param {Boolean} intoFragment If set, children are rendered into `DocumentFragment`.
-	 * @returns {HTMLElement|Text} A rendered Node.
+	 * @param {module:ui/template~RenderData} data Rendering data.
 	 */
-	_renderNode( applyNode, intoFragment ) {
+	_renderNode( data ) {
 		let isInvalid;
 
-		if ( applyNode ) {
+		if ( data.node ) {
 			// When applying, a definition cannot have "tag" and "text" at the same time.
 			isInvalid = this.tag && this.text;
 		} else {
@@ -290,123 +327,205 @@ export default class Template {
 			throw new CKEditorError( 'ui-template-wrong-syntax: Node definition must have either "tag" or "text" when rendering new Node.' );
 		}
 
-		return this.text ? this._renderText( applyNode ) : this._renderElement( applyNode, intoFragment );
+		if ( this.text ) {
+			return this._renderText( data );
+		} else {
+			return this._renderElement( data );
+		}
 	}
 
 	/**
 	 * Renders an `HTMLElement` out of the template.
 	 *
 	 * @protected
-	 * @param {HTMLElement} applyElement If specified, this template will be applied to an existing `HTMLElement`.
-	 * @param {Boolean} intoFragment If set, children are rendered into `DocumentFragment`.
-	 * @returns {HTMLElement} A rendered `HTMLElement`.
+	 * @param {module:ui/template~RenderData} data Rendering data.
 	 */
-	_renderElement( applyElement, intoFragment ) {
-		const el = applyElement ||
-			document.createElementNS( this.ns || xhtmlNs, this.tag );
-
-		this._renderAttributes( el );
-
-		// Invoke children recursively.
-		if ( intoFragment ) {
-			const docFragment = document.createDocumentFragment();
+	_renderElement( data ) {
+		let node = data.node;
 
-			this._renderElementChildren( el, docFragment );
-
-			el.appendChild( docFragment );
-		} else {
-			this._renderElementChildren( el, el, !!applyElement );
+		if ( !node ) {
+			node = data.node = document.createElementNS( this.ns || xhtmlNs, this.tag );
 		}
 
-		// Setup DOM bindings event listeners.
-		this._setUpListeners( el );
+		this._renderAttributes( data );
+		this._renderElementChildren( data );
+		this._setUpListeners( data );
 
-		return el;
+		return node;
 	}
 
 	/**
 	 * Renders a `Text` node out of {@link module:ui/template~Template#text}.
 	 *
 	 * @protected
-	 * @param {HTMLElement} textNode If specified, this template instance will be applied to an existing `Text` Node.
-	 * @returns {Text} A rendered `Text` node in DOM.
+	 * @param {module:ui/template~RenderData} data Rendering data.
 	 */
-	_renderText( textNode = document.createTextNode( '' ) ) {
+	_renderText( data ) {
+		let node = data.node;
+
+		// Save the original textContent to revert it in #revert().
+		if ( node ) {
+			data.revertData.text = node.textContent;
+		} else {
+			node = data.node = document.createTextNode( '' );
+		}
+
 		// Check if this Text Node is bound to Observable. Cases:
-		//		{ text: [ Template.bind( ... ).to( ... ) ] }
-		//		{ text: [ 'foo', Template.bind( ... ).to( ... ), ... ] }
+		//
+		//		text: [ Template.bind( ... ).to( ... ) ]
+		//
+		//		text: [
+		//			'foo',
+		//			Template.bind( ... ).to( ... ),
+		//			...
+		//		]
+		//
 		if ( hasTemplateBinding( this.text ) ) {
-			this._bindToObservable( this.text, textNode, getTextUpdater( textNode ) );
+			this._bindToObservable( {
+				schema: this.text,
+				updater: getTextUpdater( node ),
+				data
+			} );
 		}
-
 		// Simply set text. Cases:
-		// 		{ text: [ 'all', 'are', 'static' ] }
-		// 		{ text: [ 'foo' ] }
+		//
+		//		text: [ 'all', 'are', 'static' ]
+		//
+		//		text: [ 'foo' ]
+		//
 		else {
-			textNode.textContent = this.text.join( '' );
+			node.textContent = this.text.join( '' );
 		}
 
-		return textNode;
+		return node;
 	}
 
 	/**
 	 * Renders an `HTMLElement` attributes out of {@link module:ui/template~Template#attributes}.
 	 *
 	 * @protected
-	 * @param {HTMLElement} el `HTMLElement` which attributes are to be rendered.
+	 * @param {module:ui/template~RenderData} data Rendering data.
 	 */
-	_renderAttributes( el ) {
-		let attrName, attrValue, attrNs;
+	_renderAttributes( data ) {
+		let attrName, attrValue, domAttrValue, attrNs;
 
 		if ( !this.attributes ) {
 			return;
 		}
 
+		const node = data.node;
+		const revertData = data.revertData;
+
 		for ( attrName in this.attributes ) {
+			// Current attribute value in DOM.
+			domAttrValue = node.getAttribute( attrName );
+
+			// The value to be set.
 			attrValue = this.attributes[ attrName ];
 
+			// Save revert data.
+			if ( revertData ) {
+				revertData.attributes[ attrName ] = domAttrValue;
+			}
+
 			// Detect custom namespace:
-			// 		{ class: { ns: 'abc', value: Template.bind( ... ).to( ... ) } }
-			attrNs = isObject( attrValue[ 0 ] ) && attrValue[ 0 ].ns ? attrValue[ 0 ].ns : null;
+			//
+			//		class: {
+			//			ns: 'abc',
+			//			value: Template.bind( ... ).to( ... )
+			//		}
+			//
+			attrNs = ( isObject( attrValue[ 0 ] ) && attrValue[ 0 ].ns ) ? attrValue[ 0 ].ns : null;
 
 			// Activate binding if one is found. Cases:
-			// 		{ class: [ Template.bind( ... ).to( ... ) ] }
-			// 		{ class: [ 'bar', Template.bind( ... ).to( ... ), 'baz' ] }
-			// 		{ class: { ns: 'abc', value: Template.bind( ... ).to( ... ) } }
+			//
+			//		class: [
+			//			Template.bind( ... ).to( ... )
+			//		]
+			//
+			//		class: [
+			//			'bar',
+			//			Template.bind( ... ).to( ... ),
+			//			'baz'
+			//		]
+			//
+			//		class: {
+			//			ns: 'abc',
+			//			value: Template.bind( ... ).to( ... )
+			//		}
+			//
 			if ( hasTemplateBinding( attrValue ) ) {
-				this._bindToObservable(
-					// Normalize attributes with additional data like namespace:
-					//		{ class: { ns: 'abc', value: [ ... ] } }
-					attrNs ? attrValue[ 0 ].value : attrValue,
-					el,
-					getAttributeUpdater( el, attrName, attrNs )
-				);
+				// Normalize attributes with additional data like namespace:
+				//
+				//		class: {
+				//			ns: 'abc',
+				//			value: [ ... ]
+				//		}
+				//
+				const valueToBind = attrNs ? attrValue[ 0 ].value : attrValue;
+
+				// Extend the original value of attributes like "style" and "class",
+				// don't override them.
+				if ( revertData && shouldExtend( attrName ) ) {
+					valueToBind.unshift( domAttrValue );
+				}
+
+				this._bindToObservable( {
+					schema: valueToBind,
+					updater: getAttributeUpdater( node, attrName, attrNs ),
+					data
+				} );
 			}
 
 			// Style attribute could be an Object so it needs to be parsed in a specific way.
+			//
 			//		style: {
 			//			width: '100px',
 			//			height: Template.bind( ... ).to( ... )
 			//		}
+			//
 			else if ( attrName == 'style' && typeof attrValue[ 0 ] !== 'string' ) {
-				this._renderStyleAttribute( attrValue[ 0 ], el );
+				this._renderStyleAttribute( attrValue[ 0 ], data );
 			}
 
-			// Otherwise simply set the static attribute.
-			// 		{ class: [ 'foo' ] }
-			// 		{ class: [ 'all', 'are', 'static' ] }
-			// 		{ class: [ { ns: 'abc', value: [ 'foo' ] } ] }
+			// Otherwise simply set the static attribute:
+			//
+			//		class: [ 'foo' ]
+			//
+			//		class: [ 'all', 'are', 'static' ]
+			//
+			//		class: [
+			//			{
+			//				ns: 'abc',
+			//				value: [ 'foo' ]
+			//			}
+			//		]
+			//
 			else {
+				// Extend the original value of attributes like "style" and "class",
+				// don't override them.
+				if ( revertData && domAttrValue && shouldExtend( attrName ) ) {
+					attrValue.unshift( domAttrValue );
+				}
+
 				attrValue = attrValue
-					// Retrieve "values" from { class: [ { ns: 'abc', value: [ ... ] } ] }
-					.map( v => v ? ( v.value || v ) : v )
+					// Retrieve "values" from:
+					//
+					//		class: [
+					//			{
+					//				ns: 'abc',
+					//				value: [ ... ]
+					//			}
+					//		]
+					//
+					.map( val => val ? ( val.value || val ) : val )
 					// Flatten the array.
-					.reduce( ( p, n ) => p.concat( n ), [] )
+					.reduce( ( prev, next ) => prev.concat( next ), [] )
 					// Convert into string.
 					.reduce( arrayValueReducer, '' );
 
 				if ( !isFalsy( attrValue ) ) {
-					el.setAttributeNS( attrNs, attrName, attrValue );
+					node.setAttributeNS( attrNs, attrName, attrValue );
 				}
 			}
 		}
@@ -418,42 +537,54 @@ export default class Template {
 	 * Style attribute is an {Object} with static values:
 	 *
 	 *		attributes: {
-	 * 			style: {
-	 * 				color: 'red'
-	 * 			}
-	 * 		}
+	 *			style: {
+	 *				color: 'red'
+	 *			}
+	 *		}
 	 *
 	 * or values bound to {@link module:ui/model~Model} properties:
 	 *
 	 *		attributes: {
-	 * 			style: {
-	 * 				color: bind.to( ... )
-	 * 			}
-	 * 		}
+	 *			style: {
+	 *				color: bind.to( ... )
+	 *			}
+	 *		}
 	 *
 	 * Note: `style` attribute is rendered without setting the namespace. It does not seem to be
 	 * needed.
 	 *
 	 * @private
-	 * @param {Object} styles module:ui/template~TemplateDefinition.attributes.styles Styles definition.
-	 * @param {HTMLElement} el `HTMLElement` which `style` attribute is rendered.
+	 * @param {Object} styles Styles located in `attributes.style` of {@link module:ui/template~TemplateDefinition}.
+	 * @param {module:ui/template~RenderData} data Rendering data.
 	 */
-	_renderStyleAttribute( styles, el ) {
+	_renderStyleAttribute( styles, data ) {
+		const node = data.node;
+
 		for ( let styleName in styles ) {
 			const styleValue = styles[ styleName ];
 
-			// style: {
-			//	color: bind.to( 'attribute' )
-			// }
+			// Cases:
+			//
+			//		style: {
+			//			color: bind.to( 'attribute' )
+			//		}
+			//
 			if ( hasTemplateBinding( styleValue ) ) {
-				this._bindToObservable( [ styleValue ], el, getStyleUpdater( el, styleName ) );
+				this._bindToObservable( {
+					schema: [ styleValue ],
+					updater: getStyleUpdater( node, styleName ),
+					data
+				} );
 			}
 
-			// style: {
-			//	color: 'red'
-			// }
+			// Cases:
+			//
+			//		style: {
+			//			color: 'red'
+			//		}
+			//
 			else {
-				el.style[ styleName ] = styleValue;
+				node.style[ styleName ] = styleValue;
 			}
 		}
 	}
@@ -462,54 +593,72 @@ export default class Template {
 	 * Recursively renders `HTMLElement` children from {@link module:ui/template~Template#children}.
 	 *
 	 * @protected
-	 * @param {HTMLElement} element The element which is being rendered.
-	 * @param {HTMLElement|DocumentFragment} container `HTMLElement` or `DocumentFragment`
-	 * into which children are being rendered. If `shouldApply == true`, then `container === element`.
-	 * @param {Boolean} shouldApply Traverse existing DOM structure only, don't modify DOM.
+	 * @param {module:ui/template~RenderData} data Rendering data.
 	 */
-	_renderElementChildren( element, container, shouldApply ) {
+	_renderElementChildren( data ) {
+		const node = data.node;
+		const container = data.intoFragment ? document.createDocumentFragment() : node;
+		const isApplying = data.isApplying;
 		let childIndex = 0;
 
 		for ( let child of this.children ) {
 			if ( isViewCollection( child ) ) {
-				if ( !shouldApply ) {
-					child.setParent( element );
+				if ( !isApplying ) {
+					child.setParent( node );
 
 					for ( let view of child ) {
 						container.appendChild( view.element );
 					}
 				}
 			} else if ( isView( child ) ) {
-				if ( !shouldApply ) {
+				if ( !isApplying ) {
 					container.appendChild( child.element );
 				}
 			} else {
-				if ( shouldApply ) {
-					child._renderNode( container.childNodes[ childIndex++ ] );
+				if ( isApplying ) {
+					const revertData = data.revertData;
+					const childRevertData = getEmptyRevertData();
+
+					revertData.children.push( childRevertData );
+
+					child._renderNode( {
+						node: container.childNodes[ childIndex++ ],
+						isApplying: true,
+						revertData: childRevertData
+					} );
 				} else {
 					container.appendChild( child.render() );
 				}
 			}
 		}
+
+		if ( data.intoFragment ) {
+			node.appendChild( container );
+		}
 	}
 
 	/**
-	 * Activates ~Template#on listeners on a passed `HTMLElement`.
+	 * Activates `on` listeners in the {@link module:ui/template~TemplateDefinition}
+	 * on a passed `HTMLElement`.
 	 *
 	 * @protected
-	 * @param {HTMLElement} el `HTMLElement` which is being rendered.
+	 * @param {module:ui/template~RenderData} data Rendering data.
 	 */
-	_setUpListeners( el ) {
+	_setUpListeners( data ) {
 		if ( !this.eventListeners ) {
 			return;
 		}
 
 		for ( let key in this.eventListeners ) {
-			const [ domEvtName, domSelector ] = key.split( '@' );
+			const revertBindings = this.eventListeners[ key ].map( schemaItem => {
+				const [ domEvtName, domSelector ] = key.split( '@' );
 
-			this.eventListeners[ key ].forEach( schemaItem => {
-				schemaItem.activateDomEventListener( el, domEvtName, domSelector );
+				return schemaItem.activateDomEventListener( domEvtName, domSelector, data );
 			} );
+
+			if ( data.revertData ) {
+				data.revertData.bindings.push( revertBindings );
+			}
 		}
 	}
 
@@ -520,12 +669,18 @@ export default class Template {
 	 * Note: {@link module:ui/template~TemplateValueSchema} can be for HTMLElement attributes or Text Node `textContent`.
 	 *
 	 * @protected
-	 * @param {module:ui/template~TemplateValueSchema} valueSchema
-	 * @param {Node} node DOM Node to be updated when {@link module:utils/observablemixin~ObservableMixin} changes.
-	 * @param {Function} domUpdater A function which updates DOM (like attribute or text).
+	 * @param {Object} options Binding options.
+	 * @param {module:ui/template~TemplateValueSchema} options.schema
+	 * @param {Function} options.updater A function which updates DOM (like attribute or text).
+	 * @param {module:ui/template~RenderData} options.data Rendering data.
 	 */
-	_bindToObservable( valueSchema ) {
-		valueSchema
+	_bindToObservable( { schema, updater, data } ) {
+		const revertData = data.revertData;
+
+		// Set initial values.
+		syncValueSchemaValue( schema, updater, data );
+
+		const revertBindings = schema
 			// Filter "falsy" (false, undefined, null, '') value schema components out.
 			.filter( item => !isFalsy( item ) )
 			// Filter inactive bindings from schema, like static strings ('foo'), numbers (42), etc.
@@ -533,10 +688,59 @@ export default class Template {
 			// Once only the actual binding are left, let the emitter listen to observable change:attribute event.
 			// TODO: Reduce the number of listeners attached as many bindings may listen
 			// to the same observable attribute.
-			.forEach( templateBinding => templateBinding.activateAttributeListener( ...arguments ) );
+			.map( templateBinding => templateBinding.activateAttributeListener( schema, updater, data ) );
 
-		// Set initial values.
-		syncValueSchemaValue( ...arguments );
+		if ( revertData ) {
+			revertData.bindings.push( revertBindings );
+		}
+	}
+
+	/**
+	 * Reverts {@link module:ui/template~RenderData#revertData template data} from a node to
+	 * return it to the the original state.
+	 *
+	 * @protected
+	 * @param {HTMLElement|Text} node A node to be reverted.
+	 * @param {module:ui/template~RenderData#revertData} revertData Stores information about
+	 * what changes have been made by {@link #apply} to the node.
+	 */
+	_revertTemplateFromNode( node, revertData ) {
+		for ( let binding of revertData.bindings ) {
+			// Each binding may consist of several observable+observable#attribute.
+			// like the following has 2:
+			//
+			//		class: [
+			//			'x',
+			//			bind.to( 'foo' ),
+			//			'y',
+			//			bind.to( 'bar' )
+			//		]
+			//
+			for ( let revertBinding of binding ) {
+				revertBinding();
+			}
+		}
+
+		if ( revertData.text ) {
+			node.textContent = revertData.text;
+
+			return;
+		}
+
+		for ( let attrName in revertData.attributes ) {
+			const attrValue = revertData.attributes[ attrName ];
+
+			// When the attribute has **not** been set before #apply().
+			if ( attrValue === null ) {
+				node.removeAttribute( attrName );
+			} else {
+				node.setAttribute( attrName, attrValue );
+			}
+		}
+
+		for ( let i = 0; i < revertData.children.length; ++i ) {
+			this._revertTemplateFromNode( node.childNodes[ i ], revertData.children[ i ] );
+		}
 	}
 }
 
@@ -593,10 +797,10 @@ export class TemplateBinding {
 	 * @param {Node} [node] A native DOM node, passed to the custom {@link module:ui/template~TemplateBinding#callback}.
 	 * @returns {*} The value of {@link module:ui/template~TemplateBinding#attribute} in {@link module:ui/template~TemplateBinding#observable}.
 	 */
-	getValue( domNode ) {
+	getValue( node ) {
 		const value = this.observable[ this.attribute ];
 
-		return this.callback ? this.callback( value, domNode ) : value;
+		return this.callback ? this.callback( value, node ) : value;
 	}
 
 	/**
@@ -607,14 +811,20 @@ export class TemplateBinding {
 	 * For instance, the `class` attribute of the `Template` element can be be bound to
 	 * the observable `foo` attribute in `ObservableMixin` instance.
 	 *
-	 * @param {module:ui/template~TemplateValueSchema} valueSchema A full schema to generate an attribute or text in DOM.
-	 * @param {Node} node A native DOM node, which attribute or text is to be updated.
+	 * @param {module:ui/template~TemplateValueSchema} schema A full schema to generate an attribute or text in DOM.
 	 * @param {Function} updater A DOM updater function used to update native DOM attribute or text.
+	 * @param {module:ui/template~RenderData} data Rendering data.
+	 * @returns {Function} A function to sever the listener binding.
 	 */
-	activateAttributeListener( valueSchema, node, updater ) {
-		this.emitter.listenTo( this.observable, 'change:' + this.attribute, () => {
-			syncValueSchemaValue( valueSchema, node, updater );
-		} );
+	activateAttributeListener( schema, updater, data ) {
+		const callback = () => syncValueSchemaValue( schema, updater, data );
+
+		this.emitter.listenTo( this.observable, 'change:' + this.attribute, callback );
+
+		// Allows revert of the listener.
+		return () => {
+			this.emitter.stopListening( this.observable, 'change:' + this.attribute, callback );
+		};
 	}
 }
 
@@ -633,12 +843,13 @@ export class TemplateToBinding extends TemplateBinding {
 	 * Activates the listener for the native DOM event, which when fired, is propagated by
 	 * the {@link module:ui/template~TemplateBinding#emitter}.
 	 *
-	 * @param {HTMLElement} element An element on which listening to the native DOM event.
 	 * @param {String} domEvtName A name of the native DOM event.
-	 * @param {String} [domSelector] A selector in DOM to filter delegated events.
+	 * @param {String} domSelector A selector in DOM to filter delegated events.
+	 * @param {module:ui/template~RenderData} data Rendering data.
+	 * @returns {Function} A function to sever the listener binding.
 	 */
-	activateDomEventListener( el, domEvtName, domSelector ) {
-		this.emitter.listenTo( el, domEvtName, ( evt, domEvt ) => {
+	activateDomEventListener( domEvtName, domSelector, data ) {
+		const callback = ( evt, domEvt ) => {
 			if ( !domSelector || domEvt.target.matches( domSelector ) ) {
 				if ( typeof this.eventNameOrFunction == 'function' ) {
 					this.eventNameOrFunction( domEvt );
@@ -646,7 +857,14 @@ export class TemplateToBinding extends TemplateBinding {
 					this.observable.fire( this.eventNameOrFunction, domEvt );
 				}
 			}
-		} );
+		};
+
+		this.emitter.listenTo( data.node, domEvtName, callback );
+
+		// Allows revert of the listener.
+		return () => {
+			this.emitter.stopListening( data.node, domEvtName, callback );
+		};
 	}
 }
 
@@ -660,8 +878,8 @@ export class TemplateIfBinding extends TemplateBinding {
 	/**
 	 * @inheritDoc
 	 */
-	getValue( domNode ) {
-		const value = super.getValue( domNode );
+	getValue( node ) {
+		const value = super.getValue( node );
 
 		return isFalsy( value ) ? false : ( this.valueIfTrue || true );
 	}
@@ -677,22 +895,27 @@ export class TemplateIfBinding extends TemplateBinding {
 // Checks whether given {@link module:ui/template~TemplateValueSchema} contains a
 // {@link module:ui/template~TemplateBinding}.
 //
-// @param {module:ui/template~TemplateValueSchema} valueSchema
+// @param {module:ui/template~TemplateValueSchema} schema
 // @returns {Boolean}
-function hasTemplateBinding( valueSchema ) {
-	if ( !valueSchema ) {
+function hasTemplateBinding( schema ) {
+	if ( !schema ) {
 		return false;
 	}
 
 	// Normalize attributes with additional data like namespace:
-	// 		class: { ns: 'abc', value: [ ... ] }
-	if ( valueSchema.value ) {
-		valueSchema = valueSchema.value;
+	//
+	//		class: {
+	//			ns: 'abc',
+	//			value: [ ... ]
+	//		}
+	//
+	if ( schema.value ) {
+		schema = schema.value;
 	}
 
-	if ( Array.isArray( valueSchema ) ) {
-		return valueSchema.some( hasTemplateBinding );
-	} else if ( valueSchema instanceof TemplateBinding ) {
+	if ( Array.isArray( schema ) ) {
+		return schema.some( hasTemplateBinding );
+	} else if ( schema instanceof TemplateBinding ) {
 		return true;
 	}
 
@@ -703,14 +926,14 @@ function hasTemplateBinding( valueSchema ) {
 // an Array. Each entry of an Array corresponds to one of {@link module:ui/template~TemplateValueSchema}
 // items.
 //
-// @param {module:ui/template~TemplateValueSchema} valueSchema
+// @param {module:ui/template~TemplateValueSchema} schema
 // @param {Node} node DOM Node updated when {@link module:utils/observablemixin~ObservableMixin} changes.
 // @return {Array}
-function getValueSchemaValue( valueSchema, domNode ) {
-	return valueSchema.map( schemaItem => {
+function getValueSchemaValue( schema, node ) {
+	return schema.map( schemaItem => {
 		// Process {@link module:ui/template~TemplateBinding} bindings.
 		if ( schemaItem instanceof TemplateBinding ) {
-			return schemaItem.getValue( domNode );
+			return schemaItem.getValue( node );
 		}
 
 		// All static values like strings, numbers, and "falsy" values (false, null, undefined, '', etc.) just pass.
@@ -721,24 +944,26 @@ function getValueSchemaValue( valueSchema, domNode ) {
 // A function executed each time bound Observable attribute changes, which updates DOM with a value
 // constructed from {@link module:ui/template~TemplateValueSchema}.
 //
-// @param {module:ui/template~TemplateValueSchema} valueSchema
+// @param {module:ui/template~TemplateValueSchema} schema
+// @param {Function} updater A function which updates DOM (like attribute or text).
 // @param {Node} node DOM Node updated when {@link module:utils/observablemixin~ObservableMixin} changes.
-// @param {Function} domUpdater A function which updates DOM (like attribute or text).
-function syncValueSchemaValue( valueSchema, domNode, domUpdater ) {
-	let value = getValueSchemaValue( valueSchema, domNode );
-
-	// Check if valueSchema is a single Template.bind.if, like:
-	//		{ class: Template.bind.if( 'foo' ) }
-	if ( valueSchema.length == 1 && valueSchema[ 0 ] instanceof TemplateIfBinding ) {
+function syncValueSchemaValue( schema, updater, { node } ) {
+	let value = getValueSchemaValue( schema, node );
+
+	// Check if schema is a single Template.bind.if, like:
+	//
+	//		class: Template.bind.if( 'foo' )
+	//
+	if ( schema.length == 1 && schema[ 0 ] instanceof TemplateIfBinding ) {
 		value = value[ 0 ];
 	} else {
 		value = value.reduce( arrayValueReducer, '' );
 	}
 
 	if ( isFalsy( value ) ) {
-		domUpdater.remove();
+		updater.remove();
 	} else {
-		domUpdater.set( value );
+		updater.set( value );
 	}
 }
 
@@ -1108,6 +1333,27 @@ function isViewCollection( item ) {
 	return item instanceof ViewCollection;
 }
 
+// Creates an empty skeleton for {@link module:ui/template~Template#revert}
+// data.
+//
+// @private
+function getEmptyRevertData() {
+	return {
+		children: [],
+		bindings: [],
+		attributes: {}
+	};
+}
+
+// Checks whether an attribute should be extended when
+// {@link module:ui/template~Template#apply} is called.
+//
+// @private
+// @param {String} attrName Attribute name to check.
+function shouldExtend( attrName ) {
+	return attrName == 'class' || attrName == 'style';
+}
+
 /**
  * A definition of {@link module:ui/template~Template}.
  * See: {@link module:ui/template~TemplateValueSchema}.
@@ -1313,3 +1559,39 @@ function isViewCollection( item ) {
  * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
  * @return {module:ui/template~TemplateBinding}
  */
+
+/**
+ * The {@link module:ui/template~Template#_renderNode} configuration.
+ *
+ * @private
+ * @interface module:ui/template~RenderData
+ */
+
+/**
+ * Tells {@link module:ui/template~Template#_renderNode} to render
+ * children into `DocumentFragment` first and then append the fragment
+ * to the parent element. It's a speed optimization.
+ *
+ * @member {Boolean} #intoFragment
+ */
+
+/**
+ * A node which is being rendered.
+ *
+ * @member {HTMLElement|Text} #node
+ */
+
+/**
+ * Indicates whether the {@module:ui/template~RenderNodeOptions#node} has
+ * been provided by {@module:ui/template~Template#apply}.
+ *
+ * @member {Boolean} #isApplying
+ */
+
+/**
+ * An object storing the data that helps {@module:ui/template~Template#revert}
+ * bringing back an element to its initial state, i.e. before
+ * {@module:ui/template~Template#apply} was called.
+ *
+ * @member {Object} #revertData
+ */

+ 48 - 0
packages/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus.js

@@ -0,0 +1,48 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/toolbar/enabletoolbarkeyboardfocus
+ */
+
+/**
+ * Enables focus/blur toolbar navigation using `Alt+F10` and `Esc` keystrokes.
+ *
+ * @param {Object} options Options of the utility.
+ * @param {*} options.origin A view to which the focus will return when `Esc` is pressed and
+ * `options.toolbar` is focused.
+ * @param {module:utils/keystrokehandler~KeystrokeHandler} options.originKeystrokeHandler A keystroke
+ * handler to register `Alt+F10` keystroke.
+ * @param {module:utils/focustracker~FocusTracker} options.originFocusTracker A focus tracker
+ * for `options.origin`.
+ * @param {module:ui/toolbar/toolbarview~ToolbarView} options.toolbar A toolbar which is to gain
+ * focus when `Alt+F10` is pressed.
+ */
+export default function enableToolbarKeyboardFocus( {
+	origin,
+	originKeystrokeHandler,
+	originFocusTracker,
+	toolbar
+} ) {
+	// Because toolbar items can get focus, the overall state of the toolbar must
+	// also be tracked.
+	originFocusTracker.add( toolbar.element );
+
+	// Focus the toolbar on the keystroke, if not already focused.
+	originKeystrokeHandler.set( 'Alt+F10', ( data, cancel ) => {
+		if ( originFocusTracker.isFocused && !toolbar.focusTracker.isFocused ) {
+			toolbar.focus();
+			cancel();
+		}
+	} );
+
+	// Blur the toolbar and bring the focus back to origin.
+	toolbar.keystrokes.set( 'Esc', ( data, cancel ) => {
+		if ( toolbar.focusTracker.isFocused ) {
+			origin.focus();
+			cancel();
+		}
+	} );
+}

+ 2 - 2
packages/ckeditor5-ui/src/toolbar/toolbarview.js

@@ -43,10 +43,10 @@ export default class ToolbarView extends View {
 		this.focusTracker = new FocusTracker();
 
 		/**
-		 * Instance of the {@link module:core/keystrokehandler~KeystrokeHandler}.
+		 * Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
 		 *
 		 * @readonly
-		 * @member {module:core/keystrokehandler~KeystrokeHandler}
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
 		 */
 		this.keystrokes = new KeystrokeHandler();
 

+ 0 - 4
packages/ckeditor5-ui/src/view.js

@@ -319,10 +319,6 @@ export default class View {
 		this._unboundChildren.clear();
 		this._viewCollections.clear();
 
-		if ( this.element ) {
-			this.element.remove();
-		}
-
 		this.element = this.template = this.locale = this.t =
 			this._viewCollections = this._unboundChildren = null;
 

+ 47 - 8
packages/ckeditor5-ui/tests/editableui/editableuiview.js

@@ -6,7 +6,11 @@
 /* globals document */
 
 import EditableUIView from '../../src/editableui/editableuiview';
+import View from '../../src/view';
 import Locale from '@ckeditor/ckeditor5-utils/src/locale';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+testUtils.createSinonSandbox();
 
 describe( 'EditableUIView', () => {
 	let view, editableElement, locale;
@@ -22,6 +26,7 @@ describe( 'EditableUIView', () => {
 		it( 'sets initial values of attributes', () => {
 			expect( view.isReadOnly ).to.be.false;
 			expect( view.isFocused ).to.be.false;
+			expect( view.externalElement ).to.be.undefined;
 		} );
 
 		it( 'renders element from template when no editableElement', () => {
@@ -30,6 +35,7 @@ describe( 'EditableUIView', () => {
 			return view.init().then( () => {
 				expect( view.element ).to.equal( view.editableElement );
 				expect( view.element.classList.contains( 'ck-editor__editable' ) ).to.be.true;
+				expect( view.externalElement ).to.be.undefined;
 			} );
 		} );
 
@@ -40,6 +46,7 @@ describe( 'EditableUIView', () => {
 				expect( view.element ).to.equal( editableElement );
 				expect( view.element ).to.equal( view.editableElement );
 				expect( view.element.classList.contains( 'ck-editor__editable' ) ).to.be.true;
+				expect( view.externalElement ).to.equal( editableElement );
 			} );
 		} );
 	} );
@@ -69,14 +76,46 @@ describe( 'EditableUIView', () => {
 		} );
 	} );
 
-	describe( 'destroy', () => {
-		it( 'updates contentEditable property of editableElement', () => {
-			return new EditableUIView( locale, editableElement ).init().then( () => {
-				expect( view.editableElement.contentEditable ).to.equal( 'true' );
-			} )
-			.then( () => view.destroy() )
-			.then( () => {
-				expect( view.editableElement.contentEditable ).to.equal( 'false' );
+	describe( 'destroy()', () => {
+		it( 'calls super#destroy()', () => {
+			const spy = testUtils.sinon.spy( View.prototype, 'destroy' );
+
+			return view.destroy().then( () => {
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+
+		describe( 'when #editableElement as an argument', () => {
+			it( 'reverts contentEditable property of editableElement (was false)', () => {
+				editableElement = document.createElement( 'div' );
+				editableElement.contentEditable = false;
+
+				view = new EditableUIView( locale, editableElement );
+
+				return view.init()
+					.then( () => {
+						expect( editableElement.contentEditable ).to.equal( 'true' );
+					} )
+					.then( () => view.destroy() )
+					.then( () => {
+						expect( editableElement.contentEditable ).to.equal( 'false' );
+					} );
+			} );
+
+			it( 'reverts contentEditable property of editableElement (was true)', () => {
+				editableElement = document.createElement( 'div' );
+				editableElement.contentEditable = true;
+
+				view = new EditableUIView( locale, editableElement );
+
+				return view.init()
+					.then( () => {
+						expect( editableElement.contentEditable ).to.equal( 'true' );
+					} )
+					.then( () => view.destroy() )
+					.then( () => {
+						expect( editableElement.contentEditable ).to.equal( 'true' );
+					} );
 			} );
 		} );
 	} );

+ 1 - 1
packages/ckeditor5-ui/tests/manual/contextualtoolbar/contextualtoolbar.js

@@ -16,7 +16,7 @@ import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 
 import Template from '../../../src/template';
 import ToolbarView from '../../../src/toolbar/toolbarview';
-import BalloonPanelView from '../../../src/balloonpanel/balloonpanelview';
+import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
 
 const arrowVOffset = BalloonPanelView.arrowVerticalOffset;
 const positions = {

+ 1 - 1
packages/ckeditor5-ui/tests/manual/imagetoolbar/imagetoolbar.js

@@ -17,7 +17,7 @@ import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 
 import Template from '../../../src/template';
 import ToolbarView from '../../../src/toolbar/toolbarview';
-import BalloonPanelView from '../../../src/balloonpanel/balloonpanelview';
+import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
 
 const arrowVOffset = BalloonPanelView.arrowVerticalOffset;
 const positions = {

+ 1 - 1
packages/ckeditor5-ui/tests/manual/tickets/126/1.js

@@ -5,7 +5,7 @@
 
 /* global document, window */
 
-import BalloonPanelView from '../../../../src/balloonpanel/balloonpanelview';
+import BalloonPanelView from '../../../../src/panel/balloon/balloonpanelview';
 
 import '@ckeditor/ckeditor5-theme-lark/theme/theme.scss';
 

+ 3 - 3
packages/ckeditor5-ui/tests/balloonpanel/balloonpanelview.js

@@ -6,9 +6,9 @@
 /* global window, document */
 
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
-import ViewCollection from '../../src/viewcollection';
-import BalloonPanelView from '../../src/balloonpanel/balloonpanelview';
-import ButtonView from '../../src/button/buttonview';
+import ViewCollection from '../../../src/viewcollection';
+import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
+import ButtonView from '../../../src/button/buttonview';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import * as positionUtils from '@ckeditor/ckeditor5-utils/src/dom/position';
 

+ 229 - 0
packages/ckeditor5-ui/tests/panel/floating/floatingpanelview.js

@@ -0,0 +1,229 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document, Event */
+
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import FloatingPanelView from '../../../src/panel/floating/floatingpanelview';
+import View from '../../../src/view';
+import ViewCollection from '../../../src/viewcollection';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import * as positionUtils from '@ckeditor/ckeditor5-utils/src/dom/position';
+
+testUtils.createSinonSandbox();
+
+describe( 'FloatingPanelView', () => {
+	let locale, view, target;
+
+	beforeEach( () => {
+		locale = {};
+		view = new FloatingPanelView( locale );
+
+		target = global.document.createElement( 'a' );
+
+		global.document.body.appendChild( view.element );
+		global.document.body.appendChild( target );
+
+		view.targetElement = target;
+
+		return view.init();
+	} );
+
+	afterEach( () => {
+		view.element.remove();
+
+		return view.destroy();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should inherit from View', () => {
+			expect( view ).to.be.instanceOf( View );
+		} );
+
+		it( 'should set view#locale', () => {
+			expect( view.locale ).to.equal( locale );
+		} );
+
+		it( 'should set #isActive', () => {
+			expect( view.isActive ).to.be.false;
+		} );
+
+		it( 'should set #top', () => {
+			expect( view.top ).to.equal( 0 );
+		} );
+
+		it( 'should set #left', () => {
+			expect( view.left ).to.equal( 0 );
+		} );
+
+		it( 'should set #targetElement', () => {
+			view = new FloatingPanelView( locale );
+
+			expect( view.targetElement ).to.be.null;
+		} );
+
+		it( 'creates view#content collection', () => {
+			expect( view.content ).to.be.instanceOf( ViewCollection );
+		} );
+	} );
+
+	describe( 'template', () => {
+		it( 'should create element from template', () => {
+			expect( view.element.classList.contains( 'ck-floating-panel' ) ).to.be.true;
+		} );
+
+		describe( 'bindings', () => {
+			describe( 'class', () => {
+				it( 'reacts on #isActive', () => {
+					view.isActive = false;
+					expect( view.element.classList.contains( 'ck-floating-panel_active' ) ).to.be.false;
+
+					view.isActive = true;
+					expect( view.element.classList.contains( 'ck-floating-panel_active' ) ).to.be.true;
+				} );
+			} );
+
+			describe( 'style', () => {
+				it( 'reacts on #top', () => {
+					view.top = 30;
+					expect( view.element.style.top ).to.equal( '30px' );
+				} );
+
+				it( 'reacts on #left', () => {
+					view.left = 20;
+					expect( view.element.style.left ).to.equal( '20px' );
+				} );
+			} );
+
+			describe( 'children', () => {
+				it( 'should react on view#content', () => {
+					expect( view.element.childNodes.length ).to.equal( 0 );
+
+					const item = new View();
+					item.element = document.createElement( 'div' );
+					view.content.add( item );
+
+					expect( view.element.childNodes.length ).to.equal( 1 );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'init()', () => {
+		it( 'calls #_updatePosition on window.scroll', () => {
+			const spy = sinon.spy( view, '_updatePosition' );
+
+			global.window.dispatchEvent( new Event( 'scroll', { bubbles: true } ) );
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'calls #_updatePosition on #change:isActive', () => {
+			view.isActive = false;
+
+			const spy = sinon.spy( view, '_updatePosition' );
+
+			view.isActive = true;
+			sinon.assert.calledOnce( spy );
+
+			view.isActive = false;
+			sinon.assert.calledTwice( spy );
+		} );
+	} );
+
+	describe( '_updatePosition()', () => {
+		it( 'does not update when not #isActive', () => {
+			const spy = testUtils.sinon.spy( positionUtils, 'getOptimalPosition' );
+
+			view.isActive = false;
+
+			view._updatePosition();
+			sinon.assert.notCalled( spy );
+
+			view.isActive = true;
+
+			view._updatePosition();
+
+			// Note: #_updatePosition() is called on #change:isActive.
+			sinon.assert.calledTwice( spy );
+		} );
+
+		it( 'uses getOptimalPosition() utility', () => {
+			const { nw, sw, ne, se } = FloatingPanelView.defaultPositions;
+
+			view.isActive = true;
+
+			const spy = testUtils.sinon.stub( positionUtils, 'getOptimalPosition' ).returns( {
+				top: 5,
+				left: 10
+			} );
+
+			view._updatePosition();
+
+			sinon.assert.calledWithExactly( spy, {
+				element: view.element,
+				target: target,
+				positions: [ nw, sw, ne, se ],
+				limiter: global.document.body,
+				fitInViewport: true
+			} );
+
+			expect( view.top ).to.equal( 5 );
+			expect( view.left ).to.equal( 10 );
+		} );
+	} );
+
+	describe( 'defaultPositions', () => {
+		let targetRect, panelRect, defaultPositions;
+
+		beforeEach( () => {
+			defaultPositions = FloatingPanelView.defaultPositions;
+
+			targetRect = {
+				top: 10,
+				width: 100,
+				left: 10,
+				height: 10,
+				bottom: 20
+			};
+
+			panelRect = {
+				width: 20,
+				height: 10
+			};
+		} );
+
+		it( 'should provide "nw" position', () => {
+			expect( defaultPositions.nw( targetRect, panelRect ) ).to.deep.equal( {
+				top: 0,
+				left: 10,
+				name: 'nw'
+			} );
+		} );
+
+		it( 'should provide "sw" position', () => {
+			expect( defaultPositions.sw( targetRect, panelRect ) ).to.deep.equal( {
+				top: 20,
+				left: 10,
+				name: 'sw'
+			} );
+		} );
+
+		it( 'should provide "ne" position', () => {
+			expect( defaultPositions.ne( targetRect, panelRect ) ).to.deep.equal( {
+				top: 0,
+				left: 90,
+				name: 'ne'
+			} );
+		} );
+
+		it( 'should provide "se" position', () => {
+			expect( defaultPositions.se( targetRect, panelRect ) ).to.deep.equal( {
+				top: 20,
+				left: 90,
+				name: 'se'
+			} );
+		} );
+	} );
+} );

+ 582 - 27
packages/ckeditor5-ui/tests/template.js

@@ -628,7 +628,7 @@ describe( 'Template', () => {
 		let observable, domEmitter, bind;
 
 		beforeEach( () => {
-			el = document.createElement( 'div' );
+			el = getElement( { tag: 'div' } );
 			text = document.createTextNode( '' );
 
 			observable = new Model( {
@@ -649,14 +649,6 @@ describe( 'Template', () => {
 			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
 		} );
 
-		it( 'throws when no HTMLElement passed', () => {
-			expect( () => {
-				new Template( {
-					tag: 'p'
-				} ).apply();
-			} ).to.throw( CKEditorError, /ui-template-wrong-node/ );
-		} );
-
 		it( 'accepts empty template definition', () => {
 			new Template( {} ).apply( el );
 			new Template( {} ).apply( text );
@@ -674,7 +666,21 @@ describe( 'Template', () => {
 				expect( text.textContent ).to.equal( 'abc' );
 			} );
 
-			it( 'applies new textContent to an existing Text Node of an HTMLElement', () => {
+			it( 'overrides existing textContent of a Text Node', () => {
+				text.textContent = 'foo';
+
+				new Template( {
+					text: bind.to( 'foo' )
+				} ).apply( text );
+
+				expect( text.textContent ).to.equal( 'bar' );
+
+				observable.foo = 'qux';
+
+				expect( text.textContent ).to.equal( 'qux' );
+			} );
+
+			it( 'overrides textContent of an existing Text Node in a HTMLElement', () => {
 				el.textContent = 'bar';
 
 				new Template( {
@@ -699,9 +705,26 @@ describe( 'Template', () => {
 				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="a b" x="bar"></div>' );
 			} );
 
+			it( 'manages existing attribute values ("class" vs. "non–class")', () => {
+				el.setAttribute( 'class', 'default' );
+				el.setAttribute( 'x', 'foo' );
+
+				new Template( {
+					tag: 'div',
+					attributes: {
+						'class': [ 'a', 'b' ],
+						x: 'bar'
+					}
+				} ).apply( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div class="default a b" x="bar"></div>'
+				);
+			} );
+
 			it( 'applies attributes and TextContent to a DOM tree', () => {
 				el.textContent = 'abc';
-				el.appendChild( document.createElement( 'span' ) );
+				el.appendChild( getElement( { tag: 'span' } ) );
 
 				new Template( {
 					tag: 'div',
@@ -721,6 +744,107 @@ describe( 'Template', () => {
 
 				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="parent">Children:<span class="child"></span></div>' );
 			} );
+
+			describe( 'style', () => {
+				beforeEach( () => {
+					observable = new Model( {
+						width: '10px',
+						backgroundColor: 'yellow'
+					} );
+
+					bind = Template.bind( observable, domEmitter );
+				} );
+
+				it( 'applies as a static value', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: 'color: red;'
+						}
+					} );
+
+					new Template( {
+						attributes: {
+							style: 'display: block'
+						}
+					} ).apply( el );
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="color:red;display:block;"></p>' );
+				} );
+
+				it( 'applies as a static value (Array of values)', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: [ 'color: red;', 'display: block;' ]
+						}
+					} );
+
+					new Template( {
+						attributes: {
+							style: [ 'float: left;', 'overflow: hidden;' ]
+						}
+					} ).apply( el );
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<p style="color:red;display:block;float:left;overflow:hidden;"></p>'
+					);
+				} );
+
+				it( 'applies when in an object syntax', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: {
+								width: '20px',
+							}
+						}
+					} );
+
+					new Template( {
+						attributes: {
+							style: {
+								height: '10px',
+								float: 'left',
+								backgroundColor: 'green'
+							}
+						}
+					} ).apply( el );
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal( '<p style="width:20px;height:10px;float:left;background-color:green;"></p>' );
+				} );
+
+				it( 'applies when bound to observable', () => {
+					setElement( {
+						tag: 'p',
+						attributes: {
+							style: {
+								left: '20px',
+							}
+						}
+					} );
+
+					new Template( {
+						attributes: {
+							style: {
+								width: bind.to( 'width' ),
+								float: 'left',
+								backgroundColor: 'green'
+							}
+						}
+					} ).apply( el );
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<p style="left:20px;width:10px;float:left;background-color:green;"></p>'
+					);
+
+					observable.width = '100px';
+
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<p style="left:20px;width:100px;float:left;background-color:green;"></p>'
+					);
+				} );
+			} );
 		} );
 
 		describe( 'children', () => {
@@ -785,17 +909,33 @@ describe( 'Template', () => {
 				expect( collection._parentElement ).to.be.null;
 			} );
 
-			it( 'should work for deep DOM structure', () => {
-				const childA = document.createElement( 'a' );
-				const childB = document.createElement( 'b' );
+			it( 'should work for deep DOM structure with bindings and event listeners', () => {
+				const childA = getElement( {
+					tag: 'a',
+					attributes: {
+						class: 'a1 a2'
+					},
+					children: [
+						'a'
+					]
+				} );
 
-				childA.textContent = 'anchor';
-				childB.textContent = 'bold';
+				const childB = getElement( {
+					tag: 'b',
+					attributes: {
+						class: 'b1 b2'
+					},
+					children: [
+						'b'
+					]
+				} );
 
 				el.appendChild( childA );
 				el.appendChild( childB );
 
-				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div><a>anchor</a><b>bold</b></div>' );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div><a class="a1 a2">a</a><b class="b1 b2">b</b></div>'
+				);
 
 				const spy1 = testUtils.sinon.spy();
 				const spy2 = testUtils.sinon.spy();
@@ -817,7 +957,7 @@ describe( 'Template', () => {
 								class: bind.to( 'foo', val => 'applied-A-' + val ),
 								id: 'applied-A'
 							},
-							children: [ 'Text applied to childA.' ]
+							children: [ 'applied-a' ]
 						},
 						{
 							tag: 'b',
@@ -828,7 +968,7 @@ describe( 'Template', () => {
 								class: bind.to( 'baz', val => 'applied-B-' + val ),
 								id: 'applied-B'
 							},
-							children: [ 'Text applied to childB.' ]
+							children: [ 'applied-b' ]
 						},
 						'Text which is not to be applied because it does NOT exist in original element.'
 					],
@@ -842,15 +982,15 @@ describe( 'Template', () => {
 				} ).apply( el );
 
 				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="applied-parent-qux" id="BAR">' +
-					'<a class="applied-A-bar" id="applied-A">Text applied to childA.</a>' +
-					'<b class="applied-B-qux" id="applied-B">Text applied to childB.</b>' +
+					'<a class="a1 a2 applied-A-bar" id="applied-A">applied-a</a>' +
+					'<b class="b1 b2 applied-B-qux" id="applied-B">applied-b</b>' +
 				'</div>' );
 
 				observable.foo = 'updated';
 
 				expect( normalizeHtml( el.outerHTML ) ).to.equal( '<div class="applied-parent-qux" id="UPDATED">' +
-					'<a class="applied-A-updated" id="applied-A">Text applied to childA.</a>' +
-					'<b class="applied-B-qux" id="applied-B">Text applied to childB.</b>' +
+					'<a class="a1 a2 applied-A-updated" id="applied-A">applied-a</a>' +
+					'<b class="b1 b2 applied-B-qux" id="applied-B">applied-b</b>' +
 				'</div>' );
 
 				document.body.appendChild( el );
@@ -872,6 +1012,415 @@ describe( 'Template', () => {
 		} );
 	} );
 
+	describe( 'revert()', () => {
+		let observable, domEmitter, bind;
+
+		beforeEach( () => {
+			el = getElement( { tag: 'div' } );
+
+			observable = new Model( {
+				foo: 'bar',
+				baz: 'qux'
+			} );
+
+			domEmitter = Object.create( DomEmitterMixin );
+			bind = Template.bind( observable, domEmitter );
+		} );
+
+		it( 'should throw if template is not applied', () => {
+			const tpl = new Template( {
+				tag: 'div'
+			} );
+
+			expect( () => {
+				tpl.revert( el );
+			} ).to.throw( CKEditorError, /ui-template-revert-not-applied/ );
+
+			tpl.render();
+
+			expect( () => {
+				tpl.revert( el );
+			} ).to.throw( CKEditorError, /ui-template-revert-not-applied/ );
+		} );
+
+		describe( 'text', () => {
+			it( 'should revert textContent to the initial value', () => {
+				el = getElement( {
+					tag: 'a',
+					children: [
+						'a',
+						{
+							tag: 'b',
+							children: [
+								'b'
+							]
+						}
+					]
+				} );
+
+				const tpl = new Template( {
+					children: [
+						'bar',
+						{
+							children: [
+								'qux'
+							]
+						}
+					]
+				} );
+
+				tpl.apply( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>bar<b>qux</b></a>'
+				);
+
+				tpl.revert( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>a<b>b</b></a>'
+				);
+			} );
+
+			it( 'should remove bindings', () => {
+				el = getElement( {
+					tag: 'a',
+					children: [
+						'a',
+						{
+							tag: 'b',
+							children: [
+								'b'
+							]
+						}
+					]
+				} );
+
+				const tpl = new Template( {
+					children: [
+						'foo',
+						{
+							children: [
+								{
+									text: bind.to( 'foo' )
+								}
+							]
+						}
+					]
+				} );
+
+				tpl.apply( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>foo<b>bar</b></a>'
+				);
+
+				observable.foo = 'abc';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>foo<b>abc</b></a>'
+				);
+
+				tpl.revert( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>a<b>b</b></a>'
+				);
+
+				observable.foo = 'xyz';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a>a<b>b</b></a>'
+				);
+			} );
+		} );
+
+		describe( 'attributes', () => {
+			it( 'should revert attributes to the initial values', () => {
+				el = getElement( {
+					tag: 'a',
+					attributes: {
+						foo: 'af',
+						bar: 'ab',
+					},
+					children: [
+						{
+							tag: 'b',
+							attributes: {
+								foo: 'bf',
+								bar: 'bb',
+							}
+						}
+					]
+				} );
+
+				const tpl = new Template( {
+					attributes: {
+						foo: 'af1',
+						bar: [ 'ab1', 'ab2' ],
+						baz: 'x'
+					},
+					children: [
+						{
+							attributes: {
+								foo: 'bf1'
+							}
+						}
+					]
+				} );
+
+				tpl.apply( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab1 ab2" baz="x" foo="af1">' +
+						'<b bar="bb" foo="bf1"></b>' +
+					'</a>'
+				);
+
+				tpl.revert( el );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab" foo="af">' +
+						'<b bar="bb" foo="bf"></b>' +
+					'</a>'
+				);
+			} );
+
+			it( 'should remove bindings', () => {
+				el = getElement( {
+					tag: 'a',
+					attributes: {
+						foo: 'af',
+						bar: 'ab',
+					},
+					children: [
+						{
+							tag: 'b',
+							attributes: {
+								foo: 'bf',
+								bar: 'bb',
+							}
+						}
+					]
+				} );
+
+				const tpl = new Template( {
+					attributes: {
+						foo: 'af1',
+						bar: [
+							'ab1',
+							bind.to( 'baz' )
+						]
+					},
+					children: [
+						{
+							attributes: {
+								foo: bind.to( 'foo' )
+							}
+						}
+					]
+				} );
+
+				tpl.apply( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab1 qux" foo="af1">' +
+						'<b bar="bb" foo="bar"></b>' +
+					'</a>'
+				);
+
+				observable.foo = 'x';
+				observable.baz = 'y';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab1 y" foo="af1">' +
+						'<b bar="bb" foo="x"></b>' +
+					'</a>'
+				);
+
+				tpl.revert( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab" foo="af">' +
+						'<b bar="bb" foo="bf"></b>' +
+					'</a>'
+				);
+
+				observable.foo = 'abc';
+				observable.baz = 'cba';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<a bar="ab" foo="af">' +
+						'<b bar="bb" foo="bf"></b>' +
+					'</a>'
+				);
+			} );
+
+			describe( 'style', () => {
+				beforeEach( () => {
+					observable = new Model( {
+						overflow: 'visible'
+					} );
+
+					bind = Template.bind( observable, domEmitter );
+				} );
+
+				it( 'should remove bindings', () => {
+					el = getElement( {
+						tag: 'a',
+						attributes: {
+							style: {
+								fontWeight: 'bold'
+							}
+						},
+						children: [
+							{
+								tag: 'b',
+								attributes: {
+									style: {
+										color: 'red'
+									}
+								}
+							}
+						]
+					} );
+
+					const tpl = new Template( {
+						attributes: {
+							style: {
+								overflow: bind.to( 'overflow' )
+							}
+						},
+						children: [
+							{
+								tag: 'b',
+								attributes: {
+									style: {
+										display: 'block'
+									}
+								}
+							}
+						]
+					} );
+
+					tpl.apply( el );
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<a style="font-weight:bold;overflow:visible;">' +
+							'<b style="color:red;display:block;"></b>' +
+						'</a>'
+					);
+
+					tpl.revert( el );
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<a style="font-weight:bold;">' +
+							'<b style="color:red;"></b>' +
+						'</a>'
+					);
+
+					observable.overflow = 'hidden';
+					expect( normalizeHtml( el.outerHTML ) ).to.equal(
+						'<a style="font-weight:bold;">' +
+							'<b style="color:red;"></b>' +
+						'</a>'
+					);
+				} );
+			} );
+		} );
+
+		describe( 'children', () => {
+			it( 'should work for deep DOM structure with bindings and event listeners', () => {
+				el = getElement( {
+					tag: 'div',
+					children: [
+						{
+							tag: 'a',
+							attributes: {
+								class: [ 'a1', 'a2' ]
+							},
+							children: [
+								'a'
+							]
+						},
+						{
+							tag: 'b',
+							attributes: {
+								class: [ 'b1', 'b2' ]
+							},
+							children: [
+								'b'
+							]
+						}
+					]
+				} );
+
+				const spy = sinon.spy();
+				observable.on( 'ku', spy );
+
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div><a class="a1 a2">a</a><b class="b1 b2">b</b></div>'
+				);
+
+				const tpl = new Template( {
+					tag: 'div',
+					attributes: {
+						class: [ 'div1', 'div2' ],
+						style: {
+							fontWeight: 'bold'
+						}
+					},
+					children: [
+						{
+							tag: 'a',
+							attributes: {
+								class: [ 'x', 'y' ],
+								'data-new-attr': 'foo'
+							},
+							children: [ 'applied-a' ]
+						},
+						{
+							tag: 'b',
+							attributes: {
+								class: [
+									'a',
+									'b',
+									bind.to( 'foo' )
+								]
+							},
+							children: [ 'applied-b' ]
+						}
+					],
+					on: {
+						keyup: bind.to( 'ku' )
+					}
+				} );
+
+				tpl.apply( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div class="div1 div2" style="font-weight:bold;">' +
+						'<a class="a1 a2 x y" data-new-attr="foo">applied-a</a>' +
+						'<b class="b1 b2 a b bar">applied-b</b>' +
+					'</div>'
+				);
+
+				observable.foo = 'baz';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div class="div1 div2" style="font-weight:bold;">' +
+						'<a class="a1 a2 x y" data-new-attr="foo">applied-a</a>' +
+						'<b class="b1 b2 a b baz">applied-b</b>' +
+					'</div>'
+				);
+
+				dispatchEvent( el.firstChild, 'keyup' );
+				sinon.assert.calledOnce( spy );
+
+				tpl.revert( el );
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div><a class="a1 a2">a</a><b class="b1 b2">b</b></div>'
+				);
+
+				observable.foo = 'qux';
+				expect( normalizeHtml( el.outerHTML ) ).to.equal(
+					'<div><a class="a1 a2">a</a><b class="b1 b2">b</b></div>'
+				);
+
+				dispatchEvent( el.firstChild, 'keyup' );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
 	describe( 'bind()', () => {
 		it( 'returns object', () => {
 			expect( Template.bind() ).to.be.an( 'object' );
@@ -1094,7 +1643,7 @@ describe( 'Template', () => {
 
 				observable.on( 'a', spy );
 
-				const div = document.createElement( 'div' );
+				const div = getElement( { tag: 'div' } );
 				el.appendChild( div );
 
 				dispatchEvent( div, 'test' );
@@ -1573,10 +2122,12 @@ describe( 'Template', () => {
 			} );
 
 			it( 'works with Template#apply() – children', () => {
-				const el = document.createElement( 'div' );
-				const child = document.createElement( 'span' );
+				const el = getElement( { tag: 'div' } );
+				const child = getElement( {
+					tag: 'span',
+					children: [ 'foo' ]
+				} );
 
-				child.textContent = 'foo';
 				el.appendChild( child );
 
 				new Template( {
@@ -2320,6 +2871,10 @@ describe( 'Template', () => {
 	} );
 } );
 
+function getElement( template ) {
+	return new Template( template ).render();
+}
+
 function setElement( template ) {
 	el = new Template( template ).render();
 	document.body.appendChild( el );

+ 98 - 0
packages/ckeditor5-ui/tests/toolbar/enabletoolbarkeyboardfocus.js

@@ -0,0 +1,98 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+import View from '../../src/view';
+import ToolbarView from '../../src/toolbar/toolbarview';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import enableToolbarKeyboardFocus from '../../src/toolbar/enabletoolbarkeyboardfocus';
+
+describe( 'enableToolbarKeyboardFocus()', () => {
+	let origin, originFocusTracker, originKeystrokeHandler, toolbar;
+
+	beforeEach( () => {
+		origin = viewCreator();
+		originFocusTracker = new FocusTracker();
+		originKeystrokeHandler = new KeystrokeHandler();
+		toolbar = new ToolbarView();
+
+		enableToolbarKeyboardFocus( {
+			origin: origin,
+			originFocusTracker: originFocusTracker,
+			originKeystrokeHandler: originKeystrokeHandler,
+			toolbar: toolbar
+		} );
+
+		return toolbar.init();
+	} );
+
+	it( 'focuses the toolbar on Alt+F10', () => {
+		const spy = sinon.spy( toolbar, 'focus' );
+		const toolbarFocusTracker = toolbar.focusTracker;
+		const keyEvtData = {
+			keyCode: keyCodes.f10,
+			altKey: true,
+			preventDefault: sinon.spy(),
+			stopPropagation: sinon.spy()
+		};
+
+		toolbarFocusTracker.isFocused = false;
+		originFocusTracker.isFocused = false;
+
+		originKeystrokeHandler.press( keyEvtData );
+		sinon.assert.notCalled( spy );
+
+		toolbarFocusTracker.isFocused = true;
+		originFocusTracker.isFocused = true;
+
+		originKeystrokeHandler.press( keyEvtData );
+		sinon.assert.notCalled( spy );
+
+		toolbarFocusTracker.isFocused = false;
+		originFocusTracker.isFocused = true;
+
+		originKeystrokeHandler.press( keyEvtData );
+		sinon.assert.calledOnce( spy );
+
+		sinon.assert.calledOnce( keyEvtData.preventDefault );
+		sinon.assert.calledOnce( keyEvtData.stopPropagation );
+	} );
+
+	it( 're–foucuses origin on Esc', () => {
+		const spy = origin.focus = sinon.spy();
+		const toolbarFocusTracker = toolbar.focusTracker;
+		const keyEvtData = { keyCode: keyCodes.esc,
+			preventDefault: sinon.spy(),
+			stopPropagation: sinon.spy()
+		};
+
+		toolbarFocusTracker.isFocused = false;
+
+		toolbar.keystrokes.press( keyEvtData );
+		sinon.assert.notCalled( spy );
+
+		toolbarFocusTracker.isFocused = true;
+
+		toolbar.keystrokes.press( keyEvtData );
+
+		sinon.assert.calledOnce( spy );
+		sinon.assert.calledOnce( keyEvtData.preventDefault );
+		sinon.assert.calledOnce( keyEvtData.stopPropagation );
+	} );
+} );
+
+function viewCreator( name ) {
+	return ( locale ) => {
+		const view = new View( locale );
+
+		view.name = name;
+		view.element = document.createElement( 'a' );
+
+		return view;
+	};
+}

+ 4 - 4
packages/ckeditor5-ui/tests/view.js

@@ -272,14 +272,14 @@ describe( 'View', () => {
 			} );
 		} );
 
-		it( 'detaches the element from DOM', () => {
+		it( 'leaves the #element in DOM', () => {
 			const elRef = view.element;
+			const parentEl = document.createElement( 'div' );
 
-			document.createElement( 'div' ).appendChild( view.element );
-			expect( elRef.parentNode ).to.be.not.null;
+			parentEl.appendChild( view.element );
 
 			return view.destroy().then( () => {
-				expect( elRef.parentNode ).to.be.null;
+				expect( elRef.parentNode ).to.equal( parentEl );
 			} );
 		} );
 

packages/ckeditor5-ui/theme/components/balloonpanel.scss → packages/ckeditor5-ui/theme/components/panel/balloonpanel.scss


+ 11 - 0
packages/ckeditor5-ui/theme/components/panel/floatingpanel.scss

@@ -0,0 +1,11 @@
+// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+// For licensing, see LICENSE.md or http://ckeditor.com/license
+
+.ck-floating-panel {
+	position: absolute;
+	display: none;
+
+	&_active {
+		display: block;
+	}
+}

+ 8 - 6
packages/ckeditor5-ui/theme/components/stickytoolbar.scss

@@ -2,13 +2,15 @@
 // For licensing, see LICENSE.md or http://ckeditor.com/license
 
 @include ck-editor {
-	.ck-toolbar.ck-toolbar_sticky {
-		position: fixed;
-		top: 0;
+	.ck-toolbar {
+		&.ck-toolbar_sticky {
+			position: fixed;
+			top: 0;
 
-		&.ck-toolbar_sticky_bottom-limit {
-			top: auto;
-			position: absolute;
+			&.ck-toolbar_sticky_bottom-limit {
+				top: auto;
+				position: absolute;
+			}
 		}
 	}
 }

+ 14 - 0
packages/ckeditor5-ui/theme/components/toolbar/stickytoolbar.scss

@@ -0,0 +1,14 @@
+// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+// For licensing, see LICENSE.md or http://ckeditor.com/license
+
+@include ck-editor {
+	.ck-toolbar.ck-toolbar_sticky {
+		position: fixed;
+		top: 0;
+
+		&.ck-toolbar_sticky_bottom-limit {
+			top: auto;
+			position: absolute;
+		}
+	}
+}

packages/ckeditor5-ui/theme/components/toolbar.scss → packages/ckeditor5-ui/theme/components/toolbar/toolbar.scss


+ 4 - 3
packages/ckeditor5-ui/theme/theme.scss

@@ -9,10 +9,11 @@
 @import 'components/icon';
 @import 'components/tooltip';
 @import 'components/button';
-@import 'components/toolbar';
+@import 'components/toolbar/toolbar';
 @import 'components/dropdown';
 @import 'components/list';
 @import 'components/label';
-@import 'components/balloonpanel';
+@import 'components/panel/balloonpanel';
+@import 'components/panel/floatingpanel';
 @import 'components/editor';
-@import 'components/stickytoolbar';
+@import 'components/toolbar/stickytoolbar';