8
0
Quellcode durchsuchen

Merge branch 'master' into t/350

Piotrek Koszuliński vor 8 Jahren
Ursprung
Commit
46811c12bf
30 geänderte Dateien mit 912 neuen und 150 gelöschten Zeilen
  1. 71 0
      packages/ckeditor5-ui/src/dropdown/button/buttondropdownmodel.jsdoc
  2. 26 0
      packages/ckeditor5-ui/src/dropdown/button/buttondropdownview.jsdoc
  3. 113 0
      packages/ckeditor5-ui/src/dropdown/button/createbuttondropdown.js
  4. 8 3
      packages/ckeditor5-ui/src/dropdown/createdropdown.js
  5. 9 0
      packages/ckeditor5-ui/src/dropdown/dropdownmodel.jsdoc
  6. 27 0
      packages/ckeditor5-ui/src/dropdown/dropdownpanelfocusable.jsdoc
  7. 21 11
      packages/ckeditor5-ui/src/dropdown/dropdownview.js
  8. 4 51
      packages/ckeditor5-ui/src/dropdown/list/createlistdropdown.js
  9. 83 0
      packages/ckeditor5-ui/src/dropdown/utils.js
  10. 1 0
      packages/ckeditor5-ui/src/list/listview.js
  11. 1 1
      packages/ckeditor5-ui/src/toolbar/contextual/contextualtoolbar.js
  12. 29 1
      packages/ckeditor5-ui/src/toolbar/toolbarview.js
  13. 219 0
      packages/ckeditor5-ui/tests/dropdown/button/createbuttondropdown.js
  14. 48 3
      packages/ckeditor5-ui/tests/dropdown/createdropdown.js
  15. 16 0
      packages/ckeditor5-ui/tests/dropdown/dropdownview.js
  16. 4 0
      packages/ckeditor5-ui/tests/dropdown/manual/dropdown.html
  17. 42 1
      packages/ckeditor5-ui/tests/dropdown/manual/dropdown.js
  18. 5 0
      packages/ckeditor5-ui/tests/dropdown/manual/dropdown.md
  19. 9 13
      packages/ckeditor5-ui/tests/manual/contextualballoon/externalchanges.js
  20. 2 2
      packages/ckeditor5-ui/tests/manual/contextualtoolbar/contextualtoolbar.js
  21. 9 13
      packages/ckeditor5-ui/tests/manual/tickets/198/1.js
  22. 10 10
      packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js
  23. 39 37
      packages/ckeditor5-ui/tests/toolbar/contextual/contextualtoolbar.js
  24. 47 0
      packages/ckeditor5-ui/tests/toolbar/toolbarview.js
  25. 6 1
      packages/ckeditor5-ui/theme/components/button/button.css
  26. 13 0
      packages/ckeditor5-ui/theme/components/button/mixins/_button.css
  27. 16 0
      packages/ckeditor5-ui/theme/components/dropdown/buttondropdown.css
  28. 18 2
      packages/ckeditor5-ui/theme/components/dropdown/dropdown.css
  29. 3 0
      packages/ckeditor5-ui/theme/components/icon/icon.css
  30. 13 1
      packages/ckeditor5-ui/theme/components/toolbar/toolbar.css

+ 71 - 0
packages/ckeditor5-ui/src/dropdown/button/buttondropdownmodel.jsdoc

@@ -0,0 +1,71 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/dropdown/button/buttondropdownmodel
+ */
+
+/**
+ * The button dropdown model interface.
+ *
+ * @implements module:ui/dropdown/dropdownmodel~DropdownModel
+ * @interface module:ui/dropdown/button/buttondropdownmodel~ButtonDropdownModel
+ */
+
+/**
+ * List of buttons to be included in dropdown
+ *
+ * @observable
+ * @member {Array.<module:ui/button/buttonview~ButtonView>} #buttons
+ */
+
+/**
+ * Fired when the button dropdown is executed. It fires when one of the buttons
+ * {@link module:ui/button/buttonview~ButtonView#event:execute executed}.
+ *
+ * @event #execute
+ */
+
+/**
+ * Controls dropdown direction.
+ *
+ * @observable
+ * @member {Boolean} #isVertical=false
+ */
+
+/**
+ * Disables automatic button icon binding. If set to true dropdown's button {@link #icon} will be set to {@link #defaultIcon}.
+ *
+ * @observable
+ * @member {Boolean} #staticIcon=false
+ */
+
+/**
+ * Defines default icon which is used when no button is active.
+ *
+ * Also see {@link #icon}.
+ *
+ * @observable
+ * @member {String} #defaultIcon
+ */
+
+/**
+ * Button dropdown icon is set from inner button views.
+ *
+ * Also see {@link #defaultIcon} and {@link #staticIcon}.
+ *
+ * @observable
+ * @member {String} #icon
+ */
+
+/**
+ * (Optional) A CSS class set to
+ * {@link module:ui/dropdown/button/buttondropdownview~ButtonDropdownView#toolbarView}.
+ *
+ * Also see {@link module:ui/toolbar/toolbarview~ToolbarView#className `ToolbarView#className`}.
+ *
+ * @observable
+ * @member {String} #toolbarClassName
+ */

+ 26 - 0
packages/ckeditor5-ui/src/dropdown/button/buttondropdownview.jsdoc

@@ -0,0 +1,26 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/dropdown/button/createbuttondropdown
+ */
+
+/**
+ * The button dropdown view.
+ *
+ * See {@link module:ui/dropdown/button/createbuttondropdown~createButtonDropdown}.
+ *
+ * @abstract
+ * @class module:ui/dropdown/button/buttondropdownview~ButtonDropdownView
+ * @extends module:ui/dropdown/dropdownview~DropdownView
+ */
+
+/**
+ * A child toolbar of the dropdown located in the
+ * {@link module:ui/dropdown/dropdownview~DropdownView#panelView panel}.
+ *
+ * @readonly
+ * @member {module:ui/toolbar/toolbarview~ToolbarView} #toolbarView
+ */

+ 113 - 0
packages/ckeditor5-ui/src/dropdown/button/createbuttondropdown.js

@@ -0,0 +1,113 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/dropdown/button/createbuttondropdown
+ */
+
+import createDropdown from '../createdropdown';
+
+import ToolbarView from '../../toolbar/toolbarview';
+import { closeDropdownOnBlur, closeDropdownOnExecute, focusDropdownContentsOnArrows } from '../utils';
+
+import '../../../theme/components/dropdown/buttondropdown.css';
+
+/**
+ * Creates an instance of {@link module:ui/dropdown/button/buttondropdownview~ButtonDropdownView} class using
+ * a provided {@link module:ui/dropdown/button/buttondropdownmodel~ButtonDropdownModel}.
+ *
+ *		const buttons = [];
+ *
+ *		buttons.push( new ButtonView() );
+ *		buttons.push( editor.ui.componentFactory.get( 'someButton' ) );
+ *
+ *		const model = new Model( {
+ *			label: 'A button dropdown',
+ *			isVertical: true,
+ *			buttons
+ *		} );
+ *
+ *		const dropdown = createButtonDropdown( model, locale );
+ *
+ *		// Will render a vertical button dropdown labeled "A button dropdown"
+ *		// with a button group in the panel containing two buttons.
+ *		dropdown.render()
+ *		document.body.appendChild( dropdown.element );
+ *
+ * The model instance remains in control of the dropdown after it has been created. E.g. changes to the
+ * {@link module:ui/dropdown/dropdownmodel~DropdownModel#label `model.label`} will be reflected in the
+ * dropdown button's {@link module:ui/button/buttonview~ButtonView#label} attribute and in DOM.
+ *
+ * See {@link module:ui/dropdown/createdropdown~createDropdown}.
+ *
+ * @param {module:ui/dropdown/button/buttondropdownmodel~ButtonDropdownModel} model Model of the list dropdown.
+ * @param {module:utils/locale~Locale} locale The locale instance.
+ * @returns {module:ui/dropdown/button/buttondropdownview~ButtonDropdownView} The button dropdown view instance.
+ * @returns {module:ui/dropdown/dropdownview~DropdownView}
+ */
+export default function createButtonDropdown( model, locale ) {
+	// Make disabled when all buttons are disabled
+	model.bind( 'isEnabled' ).to(
+		// Bind to #isEnabled of each command...
+		...getBindingTargets( model.buttons, 'isEnabled' ),
+		// ...and set it true if any command #isEnabled is true.
+		( ...areEnabled ) => areEnabled.some( isEnabled => isEnabled )
+	);
+
+	// If defined `staticIcon` use the `defautlIcon` without binding it to active a button.
+	if ( model.staticIcon ) {
+		model.bind( 'icon' ).to( model, 'defaultIcon' );
+	} else {
+		// Make dropdown icon as any active button.
+		model.bind( 'icon' ).to(
+			// Bind to #isOn of each button...
+			...getBindingTargets( model.buttons, 'isOn' ),
+			// ...and chose the title of the first one which #isOn is true.
+			( ...areActive ) => {
+				const index = areActive.findIndex( value => value );
+
+				// If none of the commands is active, display either defaultIcon or first button icon.
+				if ( index < 0 && model.defaultIcon ) {
+					return model.defaultIcon;
+				}
+
+				return model.buttons[ index < 0 ? 0 : index ].icon;
+			}
+		);
+	}
+
+	const dropdownView = createDropdown( model, locale );
+	const toolbarView = dropdownView.toolbarView = new ToolbarView();
+
+	toolbarView.bind( 'isVertical', 'className' ).to( model, 'isVertical', 'toolbarClassName' );
+
+	model.buttons.map( view => toolbarView.items.add( view ) );
+
+	dropdownView.extendTemplate( {
+		attributes: {
+			class: [ 'ck-buttondropdown' ]
+		}
+	} );
+
+	dropdownView.panelView.children.add( toolbarView );
+
+	closeDropdownOnBlur( dropdownView );
+	closeDropdownOnExecute( dropdownView, toolbarView.items );
+	focusDropdownContentsOnArrows( dropdownView, toolbarView );
+
+	return dropdownView;
+}
+
+// Returns an array of binding components for
+// {@link module:utils/observablemixin~Observable#bind} from a set of iterable
+// buttons.
+//
+// @private
+// @param {Iterable.<module:ui/button/buttonview~ButtonView>} buttons
+// @param {String} attribute
+// @returns {Array.<String>}
+function getBindingTargets( buttons, attribute ) {
+	return Array.prototype.concat( ...buttons.map( button => [ button, attribute ] ) );
+}

+ 8 - 3
packages/ckeditor5-ui/src/dropdown/createdropdown.js

@@ -41,9 +41,14 @@ import DropdownPanelView from './dropdownpanelview';
  */
 export default function createDropdown( model, locale ) {
 	const buttonView = new ButtonView( locale );
-	buttonView.bind( 'label', 'isOn', 'isEnabled', 'withText', 'keystroke', 'tooltip' ).to( model );
-
 	const panelView = new DropdownPanelView( locale );
+	const dropdownView = new DropdownView( locale, buttonView, panelView );
+
+	dropdownView.bind( 'isEnabled' ).to( model );
+	buttonView.bind( 'label', 'isEnabled', 'withText', 'keystroke', 'tooltip', 'icon' ).to( model );
+	buttonView.bind( 'isOn' ).to( model, 'isOn', dropdownView, 'isOpen', ( isOn, isOpen ) => {
+		return isOn || isOpen;
+	} );
 
-	return new DropdownView( locale, buttonView, panelView );
+	return dropdownView;
 }

+ 9 - 0
packages/ckeditor5-ui/src/dropdown/dropdownmodel.jsdoc

@@ -49,3 +49,12 @@
  * @observable
  * @member {Boolean} #withText
  */
+
+/**
+ * (Optional) Controls the icon of the dropdown.
+ *
+ * Also see {@link module:ui/button/buttonview~ButtonView#withText}.
+ *
+ * @observable
+ * @member {Boolean} #icon
+ */

+ 27 - 0
packages/ckeditor5-ui/src/dropdown/dropdownpanelfocusable.jsdoc

@@ -0,0 +1,27 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/dropdown/dropdownpanelfocusable
+ */
+
+/**
+ * The dropdown panel interface interface for focusable contents. It provides two methods for managing focus of the contents
+ * of dropdown's panel.
+ *
+ * @interface module:ui/dropdown/dropdownpanelfocusable~DropdownPanelFocusable
+ */
+
+/**
+ * Focuses the view element or first item in view collection on opening dropdown's panel.
+ *
+ * @method #focus
+ */
+
+/**
+ * Focuses the view element or last item in view collection on opening dropdown's panel.
+ *
+ * @method #focusLast
+ */

+ 21 - 11
packages/ckeditor5-ui/src/dropdown/dropdownview.js

@@ -44,16 +44,7 @@ export default class DropdownView extends View {
 	constructor( locale, buttonView, panelView ) {
 		super( locale );
 
-		// Extend button's template before it's registered as a child of the dropdown because
-		// by doing so, its #element is rendered and any post–render template extension will
-		// not be reflected in DOM.
-		buttonView.extendTemplate( {
-			attributes: {
-				class: [
-					'ck-dropdown__button'
-				]
-			}
-		} );
+		const bind = this.bindTemplate;
 
 		/**
 		 * Button of the dropdown view. Clicking the button opens the {@link #panelView}.
@@ -87,6 +78,16 @@ export default class DropdownView extends View {
 		 */
 		this.set( 'isOpen', false );
 
+		/**
+		 * Controls whether the dropdown is enabled, i.e. it can be clicked and execute an action.
+		 *
+		 * See {@link module:ui/button/buttonview~ButtonView#isEnabled}.
+		 *
+		 * @observable
+		 * @member {Boolean} #isEnabled
+		 */
+		this.set( 'isEnabled', true );
+
 		/**
 		 * Tracks information about DOM focus in the dropdown.
 		 *
@@ -112,7 +113,8 @@ export default class DropdownView extends View {
 
 			attributes: {
 				class: [
-					'ck-dropdown'
+					'ck-dropdown',
+					bind.to( 'isEnabled', isEnabled => isEnabled ? '' : 'ck-disabled' )
 				]
 			},
 
@@ -121,6 +123,14 @@ export default class DropdownView extends View {
 				panelView
 			]
 		} );
+
+		buttonView.extendTemplate( {
+			attributes: {
+				class: [
+					'ck-dropdown__button',
+				]
+			}
+		} );
 	}
 
 	/**

+ 4 - 51
packages/ckeditor5-ui/src/dropdown/list/createlistdropdown.js

@@ -7,11 +7,10 @@
  * @module ui/dropdown/list/createlistdropdown
  */
 
-/* global document */
-
 import ListView from '../../list/listview';
 import ListItemView from '../../list/listitemview';
 import createDropdown from '../createdropdown';
+import { closeDropdownOnBlur, closeDropdownOnExecute, focusDropdownContentsOnArrows } from '../utils';
 
 /**
  * Creates an instance of {@link module:ui/dropdown/list/listdropdownview~ListDropdownView} class using
@@ -64,57 +63,11 @@ export default function createListDropdown( model, locale ) {
 		return item;
 	} );
 
-	// TODO: Delegate all events instead of just execute.
-	listView.items.delegate( 'execute' ).to( dropdownView );
-
 	dropdownView.panelView.children.add( listView );
 
-	dropdownView.on( 'change:isOpen', ( evt, name, value ) => {
-		if ( value ) {
-			attachDocumentClickListener( dropdownView );
-		} else {
-			dropdownView.stopListening( document );
-		}
-	} );
-
-	// Close the dropdown when one of the list items has been executed.
-	dropdownView.on( 'execute', () => {
-		dropdownView.isOpen = false;
-	} );
-
-	// If the dropdown panel is already open, the arrow down key should
-	// focus the first element in list.
-	dropdownView.keystrokes.set( 'arrowdown', ( data, cancel ) => {
-		if ( dropdownView.isOpen ) {
-			listView.focus();
-			cancel();
-		}
-	} );
-
-	// If the dropdown panel is already open, the arrow up key should
-	// focus the last element in the list.
-	dropdownView.keystrokes.set( 'arrowup', ( data, cancel ) => {
-		if ( dropdownView.isOpen ) {
-			listView.focusLast();
-			cancel();
-		}
-	} );
+	closeDropdownOnBlur( dropdownView );
+	closeDropdownOnExecute( dropdownView, listView.items );
+	focusDropdownContentsOnArrows( dropdownView, listView );
 
 	return dropdownView;
 }
-
-// Attaches a "click" listener in DOM to check if any element outside
-// the dropdown has been clicked.
-//
-// @private
-// @param {module:ui/dropdown/listdropdownview~ListDropdownView} dropdownView
-function attachDocumentClickListener( dropdownView ) {
-	// TODO: It will probably be focus/blur-based rather than click. It should be bound
-	// to focusmanager of some sort.
-	dropdownView.listenTo( document, 'click', ( evtInfo, { target: domEvtTarget } ) => {
-		// Collapse the dropdown when the webpage outside of the component is clicked.
-		if ( dropdownView.element != domEvtTarget && !dropdownView.element.contains( domEvtTarget ) ) {
-			dropdownView.isOpen = false;
-		}
-	} );
-}

+ 83 - 0
packages/ckeditor5-ui/src/dropdown/utils.js

@@ -0,0 +1,83 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/dropdown/utils
+ */
+
+/* global document */
+
+/**
+ * Adds a behavior to a dropdownView that focuses dropdown panel view contents on keystrokes.
+ *
+ * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView
+ * @param {module:ui/dropdown/dropdownpanelfocusable~DropdownPanelFocusable} panelViewContents
+ */
+export function focusDropdownContentsOnArrows( dropdownView, panelViewContents ) {
+	// If the dropdown panel is already open, the arrow down key should
+	// focus the first element in list.
+	dropdownView.keystrokes.set( 'arrowdown', ( data, cancel ) => {
+		if ( dropdownView.isOpen ) {
+			panelViewContents.focus();
+			cancel();
+		}
+	} );
+
+	// If the dropdown panel is already open, the arrow up key should
+	// focus the last element in the list.
+	dropdownView.keystrokes.set( 'arrowup', ( data, cancel ) => {
+		if ( dropdownView.isOpen ) {
+			panelViewContents.focusLast();
+			cancel();
+		}
+	} );
+}
+
+/**
+ * Adds a behavior to a dropdownView that closes dropdown view on any view collection item's "execute" event.
+ *
+ * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView
+ * @param {module:ui/viewcollection~ViewCollection} viewCollection
+ */
+export function closeDropdownOnExecute( dropdownView, viewCollection ) {
+	// TODO: Delegate all events instead of just execute.
+	viewCollection.delegate( 'execute' ).to( dropdownView );
+
+	// Close the dropdown when one of the list items has been executed.
+	dropdownView.on( 'execute', () => {
+		dropdownView.isOpen = false;
+	} );
+}
+
+/**
+ * Adds a behavior to a dropdownView that closes opened dropdown on user click outside the dropdown.
+ *
+ * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView
+ */
+export function closeDropdownOnBlur( dropdownView ) {
+	dropdownView.on( 'change:isOpen', ( evt, name, value ) => {
+		if ( value ) {
+			attachDocumentClickListener( document, dropdownView );
+		} else {
+			dropdownView.stopListening( document );
+		}
+	} );
+}
+
+// Attaches a "click" listener in DOM to check if any element outside
+// the dropdown has been clicked.
+//
+// @private
+// @param {module:ui/dropdown/listdropdownview~ListDropdownView} dropdownView
+function attachDocumentClickListener( document, dropdownView ) {
+	// TODO: It will probably be focus/blur-based rather than click. It should be bound
+	// to focusmanager of some sort.
+	dropdownView.listenTo( document, 'click', ( evtInfo, { target: domEvtTarget } ) => {
+		// Collapse the dropdown when the webpage outside of the component is clicked.
+		if ( dropdownView.element != domEvtTarget && !dropdownView.element.contains( domEvtTarget ) ) {
+			dropdownView.isOpen = false;
+		}
+	} );
+}

+ 1 - 0
packages/ckeditor5-ui/src/list/listview.js

@@ -18,6 +18,7 @@ import '../../theme/components/list/list.css';
  * The list view class.
  *
  * @extends module:ui/view~View
+ * @implements module:ui/dropdown/dropdownpanelfocusable~DropdownPanelFocusable
  */
 export default class ListView extends View {
 	/**

+ 1 - 1
packages/ckeditor5-ui/src/toolbar/contextual/contextualtoolbar.js

@@ -128,7 +128,7 @@ export default class ContextualToolbar extends Plugin {
 	 * @private
 	 */
 	_handleSelectionChange() {
-		const selection = this.editor.document.selection;
+		const selection = this.editor.model.document.selection;
 		const editingView = this.editor.editing.view;
 
 		this.listenTo( selection, 'change:range', ( evt, data ) => {

+ 29 - 1
packages/ckeditor5-ui/src/toolbar/toolbarview.js

@@ -21,6 +21,7 @@ import '../../theme/components/toolbar/toolbar.css';
  * The toolbar view class.
  *
  * @extends module:ui/view~View
+ * @implements module:ui/dropdown/dropdownpanelfocusable~DropdownPanelFocusable
  */
 export default class ToolbarView extends View {
 	/**
@@ -29,6 +30,8 @@ export default class ToolbarView extends View {
 	constructor( locale ) {
 		super( locale );
 
+		const bind = this.bindTemplate;
+
 		/**
 		 * Collection of the toolbar items (like buttons).
 		 *
@@ -53,6 +56,22 @@ export default class ToolbarView extends View {
 		 */
 		this.keystrokes = new KeystrokeHandler();
 
+		/**
+		 * Controls the orientation of toolbar items.
+		 *
+		 * @observable
+		 * @member {Boolean} #isVertical
+		 */
+		this.set( 'isVertical', false );
+
+		/**
+		 * An additional CSS class added to the {@link #element}.
+		 *
+		 * @observable
+		 * @member {String} #className
+		 */
+		this.set( 'className' );
+
 		/**
 		 * Helps cycling over focusable {@link #items} in the toolbar.
 		 *
@@ -77,7 +96,9 @@ export default class ToolbarView extends View {
 			tag: 'div',
 			attributes: {
 				class: [
-					'ck-toolbar'
+					'ck-toolbar',
+					bind.if( 'isVertical', 'ck-toolbar_vertical' ),
+					bind.to( 'className' )
 				]
 			},
 
@@ -120,6 +141,13 @@ export default class ToolbarView extends View {
 		this._focusCycler.focusFirst();
 	}
 
+	/**
+	 * Focuses the last focusable in {@link #items}.
+	 */
+	focusLast() {
+		this._focusCycler.focusLast();
+	}
+
 	/**
 	 * A utility which expands a plain toolbar configuration into
 	 * {@link module:ui/toolbar/toolbarview~ToolbarView#items} using a given component factory.

+ 219 - 0
packages/ckeditor5-ui/tests/dropdown/button/createbuttondropdown.js

@@ -0,0 +1,219 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document, Event */
+
+import Model from '../../../src/model';
+import createButtonDropdown from '../../../src/dropdown/button/createbuttondropdown';
+
+import ButtonView from '../../../src/button/buttonview';
+import ToolbarView from '../../../src/toolbar/toolbarview';
+
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+describe( 'createButtonDropdown', () => {
+	let view, model, locale, buttons;
+
+	beforeEach( () => {
+		locale = { t() {} };
+		buttons = [ 'foo', 'bar' ].map( icon => {
+			const button = new ButtonView();
+			button.icon = icon;
+
+			return button;
+		} );
+
+		model = new Model( {
+			isVertical: true,
+			buttons
+		} );
+
+		view = createButtonDropdown( model, locale );
+		view.render();
+		document.body.appendChild( view.element );
+	} );
+
+	afterEach( () => {
+		view.element.remove();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'sets view#locale', () => {
+			expect( view.locale ).to.equal( locale );
+		} );
+
+		describe( 'view#toolbarView', () => {
+			it( 'is created', () => {
+				const panelChildren = view.panelView.children;
+
+				expect( panelChildren ).to.have.length( 1 );
+				expect( panelChildren.get( 0 ) ).to.equal( view.toolbarView );
+				expect( view.toolbarView ).to.be.instanceof( ToolbarView );
+			} );
+
+			it( 'delegates view.toolbarView#execute to the view', done => {
+				view.on( 'execute', evt => {
+					expect( evt.source ).to.equal( view.toolbarView.items.get( 0 ) );
+					expect( evt.path ).to.deep.equal( [ view.toolbarView.items.get( 0 ), view ] );
+
+					done();
+				} );
+
+				view.toolbarView.items.get( 0 ).fire( 'execute' );
+			} );
+
+			it( 'reacts on model#isVertical', () => {
+				model.isVertical = false;
+				expect( view.toolbarView.isVertical ).to.be.false;
+
+				model.isVertical = true;
+				expect( view.toolbarView.isVertical ).to.be.true;
+			} );
+
+			it( 'reacts on model#toolbarClassName', () => {
+				expect( view.toolbarView.className ).to.be.undefined;
+
+				model.set( 'toolbarClassName', 'foo' );
+				expect( view.toolbarView.className ).to.equal( 'foo' );
+			} );
+		} );
+
+		it( 'changes view#isOpen on view#execute', () => {
+			view.isOpen = true;
+
+			view.fire( 'execute' );
+			expect( view.isOpen ).to.be.false;
+
+			view.fire( 'execute' );
+			expect( view.isOpen ).to.be.false;
+		} );
+
+		it( 'listens to view#isOpen and reacts to DOM events (valid target)', () => {
+			// Open the dropdown.
+			view.isOpen = true;
+
+			// Fire event from outside of the dropdown.
+			document.body.dispatchEvent( new Event( 'click', {
+				bubbles: true
+			} ) );
+
+			// Closed the dropdown.
+			expect( view.isOpen ).to.be.false;
+
+			// Fire event from outside of the dropdown.
+			document.body.dispatchEvent( new Event( 'click', {
+				bubbles: true
+			} ) );
+
+			// Dropdown is still closed.
+			expect( view.isOpen ).to.be.false;
+		} );
+
+		it( 'listens to view#isOpen and reacts to DOM events (invalid target)', () => {
+			// Open the dropdown.
+			view.isOpen = true;
+
+			// Event from view.element should be discarded.
+			view.element.dispatchEvent( new Event( 'click', {
+				bubbles: true
+			} ) );
+
+			// Dropdown is still open.
+			expect( view.isOpen ).to.be.true;
+
+			// Event from within view.element should be discarded.
+			const child = document.createElement( 'div' );
+			view.element.appendChild( child );
+
+			child.dispatchEvent( new Event( 'click', {
+				bubbles: true
+			} ) );
+
+			// Dropdown is still open.
+			expect( view.isOpen ).to.be.true;
+		} );
+
+		describe( 'activates keyboard navigation for the dropdown', () => {
+			it( 'so "arrowdown" focuses the #toolbarView if dropdown is open', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowdown,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+				const spy = sinon.spy( view.toolbarView, 'focus' );
+
+				view.isOpen = false;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.notCalled( spy );
+
+				view.isOpen = true;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "arrowup" focuses the last #item in #toolbarView if dropdown is open', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowup,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+				const spy = sinon.spy( view.toolbarView, 'focusLast' );
+
+				view.isOpen = false;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.notCalled( spy );
+
+				view.isOpen = true;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+
+		describe( 'icon', () => {
+			it( 'should be set to first button\'s icon if no defaultIcon defined', () => {
+				expect( view.buttonView.icon ).to.equal( view.toolbarView.items.get( 0 ).icon );
+			} );
+
+			it( 'should be bound to first button that is on', () => {
+				view.toolbarView.items.get( 1 ).isOn = true;
+
+				expect( view.buttonView.icon ).to.equal( view.toolbarView.items.get( 1 ).icon );
+
+				view.toolbarView.items.get( 0 ).isOn = true;
+				view.toolbarView.items.get( 1 ).isOn = false;
+
+				expect( view.buttonView.icon ).to.equal( view.toolbarView.items.get( 0 ).icon );
+			} );
+
+			it( 'should be set to defaultIcon if defined and on button is on', () => {
+				const model = new Model( {
+					defaultIcon: 'baz',
+					buttons
+				} );
+
+				view = createButtonDropdown( model, locale );
+				view.render();
+
+				expect( view.buttonView.icon ).to.equal( 'baz' );
+			} );
+
+			it( 'should not bind icons if staticIcon is set', () => {
+				const model = new Model( {
+					defaultIcon: 'baz',
+					staticIcon: true,
+					buttons
+				} );
+
+				view = createButtonDropdown( model, locale );
+				view.render();
+
+				expect( view.buttonView.icon ).to.equal( 'baz' );
+				view.toolbarView.items.get( 1 ).isOn = true;
+
+				expect( view.buttonView.icon ).to.equal( 'baz' );
+			} );
+		} );
+	} );
+} );

+ 48 - 3
packages/ckeditor5-ui/tests/dropdown/createdropdown.js

@@ -13,7 +13,7 @@ import ButtonView from '../../src/button/buttonview';
 const assertBinding = utilsTestUtils.assertBinding;
 
 describe( 'createDropdown', () => {
-	it( 'accepts model', () => {
+	it( 'binds button attributes to the model', () => {
 		const modelDef = {
 			label: 'foo',
 			isOn: false,
@@ -28,9 +28,54 @@ describe( 'createDropdown', () => {
 		assertBinding( view.buttonView,
 			modelDef,
 			[
-				[ model, { label: 'bar', isOn: true, isEnabled: false, withText: true, tooltip: true } ]
+				[ model, { label: 'bar', isEnabled: false, isOn: true, withText: true, tooltip: true } ]
 			],
-			{ label: 'bar', isOn: true, isEnabled: false, withText: true, tooltip: true }
+			{ label: 'bar', isEnabled: false, isOn: true, withText: true, tooltip: true }
+		);
+	} );
+
+	it( 'binds button#isOn do dropdown #isOpen and model #isOn', () => {
+		const modelDef = {
+			label: 'foo',
+			isOn: false,
+			isEnabled: true,
+			withText: false,
+			tooltip: false
+		};
+
+		const model = new Model( modelDef );
+		const view = createDropdown( model );
+
+		view.isOpen = false;
+		expect( view.buttonView.isOn ).to.be.false;
+
+		model.isOn = true;
+		expect( view.buttonView.isOn ).to.be.true;
+
+		view.isOpen = true;
+		expect( view.buttonView.isOn ).to.be.true;
+
+		model.isOn = false;
+		expect( view.buttonView.isOn ).to.be.true;
+	} );
+
+	it( 'binds dropdown#isEnabled to the model', () => {
+		const modelDef = {
+			label: 'foo',
+			isEnabled: true,
+			withText: false,
+			tooltip: false
+		};
+
+		const model = new Model( modelDef );
+		const view = createDropdown( model );
+
+		assertBinding( view,
+			{ isEnabled: true },
+			[
+				[ model, { isEnabled: false } ]
+			],
+			{ isEnabled: false }
 		);
 	} );
 

+ 16 - 0
packages/ckeditor5-ui/tests/dropdown/dropdownview.js

@@ -40,6 +40,10 @@ describe( 'DropdownView', () => {
 			expect( view.isOpen ).to.be.false;
 		} );
 
+		it( 'sets view#isEnabled true', () => {
+			expect( view.isEnabled ).to.be.true;
+		} );
+
 		it( 'creates #focusTracker instance', () => {
 			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
 		} );
@@ -90,6 +94,18 @@ describe( 'DropdownView', () => {
 					expect( values ).to.have.members( [ true, false, true ] );
 				} );
 			} );
+
+			describe( 'DOM', () => {
+				describe( 'view#element .ck-disabled class to view#isEnabled', () => {
+					it( 'is activated', () => {
+						view.isEnabled = true;
+						expect( view.element.classList.contains( 'ck-disabled' ) ).to.be.false;
+
+						view.isEnabled = false;
+						expect( view.element.classList.contains( 'ck-disabled' ) ).to.be.true;
+					} );
+				} );
+			} );
 		} );
 	} );
 

+ 4 - 0
packages/ckeditor5-ui/tests/dropdown/manual/dropdown.html

@@ -13,3 +13,7 @@
 <h2>Long label (truncated)</h2>
 
 <div id="dropdown-label" class="ck-reset_all"></div>
+
+<h2>ButtonDropdown</h2>
+
+<div id="button-dropown" class="ck-reset_all"></div>

+ 42 - 1
packages/ckeditor5-ui/tests/dropdown/manual/dropdown.js

@@ -13,11 +13,19 @@ import createListDropdown from '../../../src/dropdown/list/createlistdropdown';
 
 import testUtils from '../../_utils/utils';
 
+import alignLeftIcon from '@ckeditor/ckeditor5-core/theme/icons/object-left.svg';
+import alignRightIcon from '@ckeditor/ckeditor5-core/theme/icons/object-right.svg';
+import alignCenterIcon from '@ckeditor/ckeditor5-core/theme/icons/object-center.svg';
+import ButtonView from '../../../src/button/buttonview';
+
+import createButtonDropdown from '../../../src/dropdown/button/createbuttondropdown';
+
 const ui = testUtils.createTestUIView( {
 	dropdown: '#dropdown',
 	listDropdown: '#list-dropdown',
 	dropdownShared: '#dropdown-shared',
-	dropdownLabel: '#dropdown-label'
+	dropdownLabel: '#dropdown-label',
+	buttonDropdown: '#button-dropown'
 } );
 
 function testEmpty() {
@@ -95,7 +103,40 @@ function testLongLabel() {
 	dropdownView.panelView.element.innerHTML = 'Empty panel. There is no child view in this DropdownPanelView.';
 }
 
+function testButton() {
+	const locale = {};
+
+	const icons = { left: alignLeftIcon, right: alignRightIcon, center: alignCenterIcon };
+
+	// Buttons to be obtained from factory later on.
+	const buttons = Object.keys( icons ).map( icon => new Model( { label: icon, isEnabled: true, isOn: false, icon: icons[ icon ] } ) );
+
+	const buttonViews = buttons
+		.map( buttonModel => {
+			const buttonView = new ButtonView( locale );
+
+			buttonView.bind( 'isEnabled', 'isOn', 'icon', 'label' ).to( buttonModel );
+
+			buttonView.on( 'execute', () => console.log( `Execute: ${ buttonModel.label }` ) );
+
+			return buttonView;
+		} );
+
+	const buttonDropdownModel = new Model( {
+		isVertical: true,
+		buttons: buttonViews
+	} );
+
+	const buttonDropdown = createButtonDropdown( buttonDropdownModel, {} );
+
+	ui.buttonDropdown.add( buttonDropdown );
+
+	window.buttons = buttons;
+	window.buttonDropdownModel = buttonDropdownModel;
+}
+
 testEmpty();
 testList();
 testSharedModel();
 testLongLabel();
+testButton();

+ 5 - 0
packages/ckeditor5-ui/tests/dropdown/manual/dropdown.md

@@ -28,3 +28,8 @@ listDropdownCollection.add(
 	} )
 );
 ```
+
+## Button Dropdown
+1. Play with `buttons[ n ].isOn` to control buttonDropdown active icon.
+2. Play with `buttons[ n ].isEnabled` to control buttonDropdown disabled state (all buttons must be set to `false`).
+3. Play with `buttonDropdownModel.isVertical` to control buttonDropdown vertical/horizontal alignment.

+ 9 - 13
packages/ckeditor5-ui/tests/manual/contextualballoon/externalchanges.js

@@ -9,8 +9,6 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'
 import ContextualToolbar from '../../../src/toolbar/contextual/contextualtoolbar';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 
-import Element from '@ckeditor/ckeditor5-engine/src/model/element';
-import Text from '@ckeditor/ckeditor5-engine/src/model/text';
 import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 import Range from '@ckeditor/ckeditor5-engine/src/model/range';
 
@@ -55,18 +53,17 @@ function wait( delay ) {
 }
 
 function startExternalInsert( editor ) {
-	const document = editor.document;
-	const bath = document.batch( 'transparent' );
+	const model = editor.model;
 
 	function type( path, text ) {
 		return new Promise( resolve => {
-			let position = new Position( document.getRoot(), path );
+			let position = new Position( model.document.getRoot(), path );
 			let index = 0;
 
 			function typing() {
 				wait( 40 ).then( () => {
-					document.enqueueChanges( () => {
-						bath.insert( position, new Text( text[ index ] ) );
+					model.enqueueChange( 'transparent', writer => {
+						writer.insertText( text[ index ], position );
 						position = position.getShiftedBy( 1 );
 
 						const nextLetter = text[ ++index ];
@@ -87,8 +84,8 @@ function startExternalInsert( editor ) {
 
 	function insertNewLine( path ) {
 		return wait( 200 ).then( () => {
-			document.enqueueChanges( () => {
-				bath.insert( new Position( document.getRoot(), path ), new Element( 'paragraph' ) );
+			model.enqueueChange( 'transparent', writer => {
+				writer.insertElement( 'paragraph', new Position( model.document.getRoot(), path ) );
 			} );
 
 			return Promise.resolve();
@@ -108,12 +105,11 @@ function startExternalInsert( editor ) {
 }
 
 function startExternalDelete( editor ) {
-	const document = editor.document;
-	const bath = document.batch( 'transparent' );
+	const model = editor.model;
 
 	wait( 3000 ).then( () => {
-		document.enqueueChanges( () => {
-			bath.remove( Range.createFromPositionAndShift( new Position( document.getRoot(), [ 1 ] ), 1 ) );
+		model.enqueueChange( 'transparent', writer => {
+			writer.remove( Range.createFromPositionAndShift( new Position( model.document.getRoot(), [ 1 ] ), 1 ) );
 		} );
 	} );
 }

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

@@ -22,8 +22,8 @@ ClassicEditor
 		const contextualToolbar = editor.plugins.get( 'ContextualToolbar' );
 
 		contextualToolbar.on( 'show', evt => {
-			const selectionRange = editor.document.selection.getFirstRange();
-			const blockRange = Range.createOn( editor.document.getRoot().getChild( 0 ) );
+			const selectionRange = editor.model.document.selection.getFirstRange();
+			const blockRange = Range.createOn( editor.model.document.getRoot().getChild( 0 ) );
 
 			if ( selectionRange.containsRange( blockRange ) || selectionRange.isIntersecting( blockRange ) ) {
 				evt.stop();

+ 9 - 13
packages/ckeditor5-ui/tests/manual/tickets/198/1.js

@@ -9,8 +9,6 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 import ContextualToolbar from '../../../../src/toolbar/contextual/contextualtoolbar';
 
-import Element from '@ckeditor/ckeditor5-engine/src/model/element';
-import Text from '@ckeditor/ckeditor5-engine/src/model/text';
 import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 import Range from '@ckeditor/ckeditor5-engine/src/model/range';
 
@@ -55,18 +53,17 @@ function wait( delay ) {
 }
 
 function startExternalInsert( editor ) {
-	const document = editor.document;
-	const bath = document.batch( 'transparent' );
+	const model = editor.model;
 
 	function type( path, text ) {
 		return new Promise( resolve => {
-			let position = new Position( document.getRoot(), path );
+			let position = new Position( model.document.getRoot(), path );
 			let index = 0;
 
 			function typing() {
 				wait( 40 ).then( () => {
-					document.enqueueChanges( () => {
-						bath.insert( position, new Text( text[ index ] ) );
+					model.enqueueChange( 'transparent', writer => {
+						writer.insertText( text[ index ], position );
 						position = position.getShiftedBy( 1 );
 
 						const nextLetter = text[ ++index ];
@@ -87,8 +84,8 @@ function startExternalInsert( editor ) {
 
 	function insertNewLine( path ) {
 		return wait( 200 ).then( () => {
-			document.enqueueChanges( () => {
-				bath.insert( new Position( document.getRoot(), path ), new Element( 'paragraph' ) );
+			model.enqueueChange( 'transparent', writer => {
+				writer.insertElement( 'paragraph', new Position( model.document.getRoot(), path ) );
 			} );
 
 			return Promise.resolve();
@@ -108,12 +105,11 @@ function startExternalInsert( editor ) {
 }
 
 function startExternalDelete( editor ) {
-	const document = editor.document;
-	const bath = document.batch( 'transparent' );
+	const model = editor.model;
 
 	wait( 3000 ).then( () => {
-		document.enqueueChanges( () => {
-			bath.remove( Range.createFromPositionAndShift( new Position( document.getRoot(), [ 1 ] ), 1 ) );
+		model.enqueueChange( 'transparent', writer => {
+			writer.remove( Range.createFromPositionAndShift( new Position( model.document.getRoot(), [ 1 ] ), 1 ) );
 		} );
 	} );
 }

+ 10 - 10
packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js

@@ -74,16 +74,16 @@ describe( 'ContextualBalloon', () => {
 		} );
 
 		describe( 'positionLimiter', () => {
-			let doc, viewDocument, root;
+			let model, viewDocument, root;
 
 			beforeEach( () => {
-				doc = editor.document;
+				model = editor.model;
 				viewDocument = editor.editing.view;
 				root = viewDocument.getRoot();
 			} );
 
 			it( 'obtains the root of the selection', () => {
-				setModelData( doc, '<paragraph>[]bar</paragraph>' );
+				setModelData( model, '<paragraph>[]bar</paragraph>' );
 
 				expect( balloon.positionLimiter() ).to.equal( viewDocument.domConverter.mapViewToDom( root ) );
 			} );
@@ -95,14 +95,14 @@ describe( 'ContextualBalloon', () => {
 			} );
 
 			it( 'obtains the farthest root of the selection (nested editable)', () => {
-				doc.schema.registerItem( 'widget' );
-				doc.schema.registerItem( 'nestededitable' );
+				model.schema.registerItem( 'widget' );
+				model.schema.registerItem( 'nestededitable' );
 
-				doc.schema.objects.add( 'widget' );
+				model.schema.objects.add( 'widget' );
 
-				doc.schema.allow( { name: 'widget', inside: '$root' } );
-				doc.schema.allow( { name: 'nestededitable', inside: 'widget' } );
-				doc.schema.allow( { name: '$inline', inside: 'nestededitable' } );
+				model.schema.allow( { name: 'widget', inside: '$root' } );
+				model.schema.allow( { name: 'nestededitable', inside: 'widget' } );
+				model.schema.allow( { name: '$inline', inside: 'nestededitable' } );
 
 				buildModelConverter().for( editor.data.modelToView, editor.editing.modelToView )
 					.fromElement( 'widget' )
@@ -112,7 +112,7 @@ describe( 'ContextualBalloon', () => {
 					.fromElement( 'nestededitable' )
 					.toElement( () => new ViewEditableElement( 'figcaption', { contenteditable: 'true' } ) );
 
-				setModelData( doc, '<widget><nestededitable>[]foo</nestededitable></widget>' );
+				setModelData( model, '<widget><nestededitable>[]foo</nestededitable></widget>' );
 
 				expect( balloon.positionLimiter() ).to.equal( viewDocument.domConverter.mapViewToDom( root ) );
 			} );

+ 39 - 37
packages/ckeditor5-ui/tests/toolbar/contextual/contextualtoolbar.js

@@ -18,7 +18,7 @@ import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js';
 /* global document, setTimeout, window */
 
 describe( 'ContextualToolbar', () => {
-	let sandbox, editor, contextualToolbar, balloon, editorElement;
+	let sandbox, editor, model, selection, editingView, contextualToolbar, balloon, editorElement;
 
 	beforeEach( () => {
 		sandbox = sinon.sandbox.create();
@@ -32,19 +32,22 @@ describe( 'ContextualToolbar', () => {
 				contextualToolbar: [ 'bold', 'italic' ]
 			} )
 			.then( newEditor => {
-				newEditor.editing.view.attachDomRoot( editorElement );
-
 				editor = newEditor;
+				model = editor.model;
+				editingView = editor.editing.view;
+				selection = model.document.selection;
 				contextualToolbar = editor.plugins.get( ContextualToolbar );
 				balloon = editor.plugins.get( ContextualBalloon );
 
+				editingView.attachDomRoot( editorElement );
+
 				// There is no point to execute BalloonPanelView attachTo and pin methods so lets override it.
 				sandbox.stub( balloon.view, 'attachTo' ).returns( {} );
 				sandbox.stub( balloon.view, 'pin' ).returns( {} );
 
 				// Focus the engine.
-				editor.editing.view.isFocused = true;
-				editor.editing.view.getDomRoot().focus();
+				editingView.isFocused = true;
+				editingView.getDomRoot().focus();
 
 				// Remove all selection ranges from DOM before testing.
 				window.getSelection().removeAllRanges();
@@ -102,10 +105,10 @@ describe( 'ContextualToolbar', () => {
 		// and sinon is not able to override it.
 
 		const spy = sandbox.spy();
-		setData( editor.document, '<paragraph>[bar]</paragraph>' );
+		setData( model, '<paragraph>[bar]</paragraph>' );
 		contextualToolbar.on( '_selectionChangeDebounced', spy );
 
-		editor.document.selection.fire( 'change:range', {} );
+		selection.fire( 'change:range', {} );
 
 		// Not yet.
 		sinon.assert.notCalled( spy );
@@ -116,7 +119,7 @@ describe( 'ContextualToolbar', () => {
 			sinon.assert.notCalled( spy );
 
 			// Fire event one more time.
-			editor.document.selection.fire( 'change:range', {} );
+			selection.fire( 'change:range', {} );
 
 			// Another 100 ms waiting.
 			setTimeout( () => {
@@ -167,11 +170,11 @@ describe( 'ContextualToolbar', () => {
 			] );
 
 			balloonAddSpy = sandbox.spy( balloon, 'add' );
-			editor.editing.view.isFocused = true;
+			editingView.isFocused = true;
 		} );
 
 		it( 'should add #toolbarView to the #_balloon and attach the #_balloon to the selection for the forward selection', () => {
-			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			setData( model, '<paragraph>b[a]r</paragraph>' );
 
 			const defaultPositions = BalloonPanelView.defaultPositions;
 
@@ -199,7 +202,7 @@ describe( 'ContextualToolbar', () => {
 		// https://github.com/ckeditor/ckeditor5-ui/issues/308
 		it( 'should ignore the zero-width orphan rect if there another one preceding it for the forward selection', () => {
 			// Restore previous stubSelectionRects() call.
-			editor.editing.view.domConverter.viewRangeToDom.restore();
+			editingView.domConverter.viewRangeToDom.restore();
 
 			// Simulate an "orphan" rect preceded by a "correct" one.
 			stubSelectionRects( [
@@ -207,14 +210,14 @@ describe( 'ContextualToolbar', () => {
 				{ width: 0 }
 			] );
 
-			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			setData( model, '<paragraph>b[a]r</paragraph>' );
 
 			contextualToolbar.show();
 			expect( balloonAddSpy.firstCall.args[ 0 ].position.target() ).to.deep.equal( forwardSelectionRect );
 		} );
 
 		it( 'should add #toolbarView to the #_balloon and attach the #_balloon to the selection for the backward selection', () => {
-			setData( editor.document, '<paragraph>b[a]r</paragraph>', { lastRangeBackward: true } );
+			setData( model, '<paragraph>b[a]r</paragraph>', { lastRangeBackward: true } );
 
 			const defaultPositions = BalloonPanelView.defaultPositions;
 
@@ -240,21 +243,21 @@ describe( 'ContextualToolbar', () => {
 		} );
 
 		it( 'should update balloon position on ViewDocument#render event while balloon is added to the #_balloon', () => {
-			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			setData( model, '<paragraph>b[a]r</paragraph>' );
 
 			const spy = sandbox.spy( balloon, 'updatePosition' );
 
-			editor.editing.view.fire( 'render' );
+			editingView.fire( 'render' );
 
 			contextualToolbar.show();
 			sinon.assert.notCalled( spy );
 
-			editor.editing.view.fire( 'render' );
+			editingView.fire( 'render' );
 			sinon.assert.calledOnce( spy );
 		} );
 
 		it( 'should not add #toolbarView to the #_balloon more than once', () => {
-			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			setData( model, '<paragraph>b[a]r</paragraph>' );
 
 			contextualToolbar.show();
 			contextualToolbar.show();
@@ -265,7 +268,7 @@ describe( 'ContextualToolbar', () => {
 			Array.from( contextualToolbar.toolbarView.items ).forEach( item => {
 				item.isEnabled = false;
 			} );
-			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			setData( model, '<paragraph>b[a]r</paragraph>' );
 
 			contextualToolbar.show();
 			sinon.assert.notCalled( balloonAddSpy );
@@ -278,7 +281,7 @@ describe( 'ContextualToolbar', () => {
 
 			delete contextualToolbar.toolbarView.items.get( 0 ).isEnabled;
 
-			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			setData( model, '<paragraph>b[a]r</paragraph>' );
 
 			contextualToolbar.show();
 			sinon.assert.calledOnce( balloonAddSpy );
@@ -292,23 +295,23 @@ describe( 'ContextualToolbar', () => {
 			} );
 
 			it( 'should not be called when the editor is not focused', () => {
-				setData( editor.document, '<paragraph>b[a]r</paragraph>' );
-				editor.editing.view.isFocused = false;
+				setData( model, '<paragraph>b[a]r</paragraph>' );
+				editingView.isFocused = false;
 
 				contextualToolbar.fire( '_selectionChangeDebounced' );
 				sinon.assert.notCalled( showSpy );
 			} );
 
 			it( 'should not be called when the selection is collapsed', () => {
-				setData( editor.document, '<paragraph>b[]ar</paragraph>' );
+				setData( model, '<paragraph>b[]ar</paragraph>' );
 
 				contextualToolbar.fire( '_selectionChangeDebounced' );
 				sinon.assert.notCalled( showSpy );
 			} );
 
 			it( 'should be called when the selection is not collapsed and editor is focused', () => {
-				setData( editor.document, '<paragraph>b[a]r</paragraph>' );
-				editor.editing.view.isFocused = true;
+				setData( model, '<paragraph>b[a]r</paragraph>' );
+				editingView.isFocused = true;
 
 				contextualToolbar.fire( '_selectionChangeDebounced' );
 				sinon.assert.calledOnce( showSpy );
@@ -321,11 +324,11 @@ describe( 'ContextualToolbar', () => {
 
 		beforeEach( () => {
 			removeBalloonSpy = sandbox.stub( balloon, 'remove' ).returns( {} );
-			editor.editing.view.isFocused = true;
+			editingView.isFocused = true;
 		} );
 
 		it( 'should remove #toolbarView from the #_balloon', () => {
-			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			setData( model, '<paragraph>b[a]r</paragraph>' );
 
 			contextualToolbar.show();
 
@@ -334,14 +337,14 @@ describe( 'ContextualToolbar', () => {
 		} );
 
 		it( 'should stop update balloon position on ViewDocument#render event', () => {
-			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			setData( model, '<paragraph>b[a]r</paragraph>' );
 
 			const spy = sandbox.spy( balloon, 'updatePosition' );
 
 			contextualToolbar.show();
 			contextualToolbar.hide();
 
-			editor.editing.view.fire( 'render' );
+			editingView.fire( 'render' );
 			sinon.assert.notCalled( spy );
 		} );
 
@@ -365,7 +368,7 @@ describe( 'ContextualToolbar', () => {
 
 			contextualToolbar.on( '_selectionChangeDebounced', spy );
 
-			editor.document.selection.fire( 'change:range', { directChange: true } );
+			selection.fire( 'change:range', { directChange: true } );
 
 			contextualToolbar.destroy();
 
@@ -380,7 +383,7 @@ describe( 'ContextualToolbar', () => {
 		let showPanelSpy, hidePanelSpy;
 
 		beforeEach( () => {
-			setData( editor.document, '<paragraph>[bar]</paragraph>' );
+			setData( model, '<paragraph>[bar]</paragraph>' );
 
 			showPanelSpy = sandbox.spy( contextualToolbar, 'show' );
 			hidePanelSpy = sandbox.spy( contextualToolbar, 'hide' );
@@ -402,7 +405,7 @@ describe( 'ContextualToolbar', () => {
 			sinon.assert.calledOnce( showPanelSpy );
 			sinon.assert.notCalled( hidePanelSpy );
 
-			editor.document.selection.fire( 'change:range', { directChange: true } );
+			selection.fire( 'change:range', { directChange: true } );
 
 			sinon.assert.calledOnce( showPanelSpy );
 			sinon.assert.calledOnce( hidePanelSpy );
@@ -414,7 +417,7 @@ describe( 'ContextualToolbar', () => {
 			sinon.assert.calledOnce( showPanelSpy );
 			sinon.assert.notCalled( hidePanelSpy );
 
-			editor.document.selection.fire( 'change:range', { directChange: false } );
+			selection.fire( 'change:range', { directChange: false } );
 
 			sinon.assert.calledOnce( showPanelSpy );
 			sinon.assert.notCalled( hidePanelSpy );
@@ -427,10 +430,10 @@ describe( 'ContextualToolbar', () => {
 			sinon.assert.notCalled( hidePanelSpy );
 
 			// Collapse range silently (without firing `change:range` { directChange: true } event).
-			const range = editor.document.selection._ranges[ 0 ];
+			const range = selection._ranges[ 0 ];
 			range.end = range.start;
 
-			editor.document.selection.fire( 'change:range', { directChange: false } );
+			selection.fire( 'change:range', { directChange: false } );
 
 			sinon.assert.calledOnce( showPanelSpy );
 			sinon.assert.calledOnce( hidePanelSpy );
@@ -478,7 +481,7 @@ describe( 'ContextualToolbar', () => {
 			const spy = sandbox.spy();
 
 			contextualToolbar.on( 'show', spy );
-			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			setData( model, '<paragraph>b[a]r</paragraph>' );
 
 			contextualToolbar.show();
 			sinon.assert.calledOnce( spy );
@@ -487,7 +490,7 @@ describe( 'ContextualToolbar', () => {
 		it( 'should not show the panel when `show` event is stopped', () => {
 			const balloonAddSpy = sandbox.spy( balloon, 'add' );
 
-			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			setData( model, '<paragraph>b[a]r</paragraph>' );
 
 			contextualToolbar.on( 'show', evt => evt.stop(), { priority: 'high' } );
 
@@ -497,7 +500,6 @@ describe( 'ContextualToolbar', () => {
 	} );
 
 	function stubSelectionRects( rects ) {
-		const editingView = editor.editing.view;
 		const originalViewRangeToDom = editingView.domConverter.viewRangeToDom;
 
 		// Mock selection rect.

+ 47 - 0
packages/ckeditor5-ui/tests/toolbar/toolbarview.js

@@ -37,6 +37,10 @@ describe( 'ToolbarView', () => {
 			expect( view.locale ).to.equal( locale );
 		} );
 
+		it( 'should set view#isVertical', () => {
+			expect( view.isVertical ).to.be.false;
+		} );
+
 		it( 'should create view#children collection', () => {
 			expect( view.items ).to.be.instanceOf( ViewCollection );
 		} );
@@ -70,6 +74,30 @@ describe( 'ToolbarView', () => {
 		} );
 	} );
 
+	describe( 'element bindings', () => {
+		describe( 'class', () => {
+			it( 'reacts on view#isVertical', () => {
+				view.isVertical = false;
+				expect( view.element.classList.contains( 'ck-toolbar_vertical' ) ).to.be.false;
+
+				view.isVertical = true;
+				expect( view.element.classList.contains( 'ck-toolbar_vertical' ) ).to.be.true;
+			} );
+
+			it( 'reacts on view#className', () => {
+				view.className = 'foo';
+				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
+
+				view.className = 'bar';
+				expect( view.element.classList.contains( 'bar' ) ).to.be.true;
+
+				view.className = false;
+				expect( view.element.classList.contains( 'foo' ) ).to.be.false;
+				expect( view.element.classList.contains( 'bar' ) ).to.be.false;
+			} );
+		} );
+	} );
+
 	describe( 'render()', () => {
 		it( 'registers #items in #focusTracker', () => {
 			const view = new ToolbarView( locale );
@@ -234,6 +262,25 @@ describe( 'ToolbarView', () => {
 		} );
 	} );
 
+	describe( 'focusLast()', () => {
+		it( 'focuses the last focusable item in DOM', () => {
+			// No children to focus.
+			view.focusLast();
+
+			// The second child is focusable.
+			view.items.add( nonFocusable() );
+			view.items.add( focusable() );
+			view.items.add( focusable() );
+			view.items.add( focusable() );
+			view.items.add( nonFocusable() );
+
+			const spy = sinon.spy( view.items.get( 3 ), 'focus' );
+			view.focusLast();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+
 	describe( 'fillFromConfig()', () => {
 		let factory;
 

+ 6 - 1
packages/ckeditor5-ui/theme/components/button/button.css

@@ -3,6 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
+@import "./mixins/_button.css";
 @import "../../mixins/_unselectable.css";
 @import "../tooltip/mixins/_tooltip.css";
 
@@ -14,9 +15,13 @@ a.ck-button {
 	display: inline-block;
 	position: relative;
 
+	@mixin ck-button-icon {
+		display: inline-block;
+	}
+
 	&.ck-button_with-text {
 		& .ck-button__label {
-			display: block;
+			display: inline-block;
 		}
 	}
 

+ 13 - 0
packages/ckeditor5-ui/theme/components/button/mixins/_button.css

@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * A helper to define button–specific icon styles.
+ */
+@define-mixin ck-button-icon {
+	& .ck-icon {
+		@mixin-content;
+	}
+}

+ 16 - 0
packages/ckeditor5-ui/theme/components/dropdown/buttondropdown.css

@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck-buttondropdown {
+	& .ck-toolbar {
+		flex-wrap: nowrap;
+	}
+
+	& .ck-dropdown__panel .ck-button {
+		&:focus {
+			z-index: calc(var(--ck-z-default) + 1);
+		}
+	}
+}

+ 18 - 2
packages/ckeditor5-ui/theme/components/dropdown/dropdown.css

@@ -3,6 +3,8 @@
  * For licensing, see LICENSE.md.
  */
 
+@import "../tooltip/mixins/_tooltip.css";
+
 .ck-dropdown {
 	display: inline-block;
 	position: relative;
@@ -17,7 +19,18 @@
 
 		position: absolute;
 		top: 50%;
-		transform: translate( 0, -50% );
+		transform: translate3d( 0, -50%, 0 );
+	}
+
+	/* Dropdown button should span horizontally, e.g. in vertical toolbars */
+	& .ck-button.ck-dropdown__button {
+		width: 100%;
+
+		/* Disable main button's tooltip when the dropdown is open. Otherwise the panel may
+		partially cover the tooltip */
+		&.ck-on {
+			@mixin ck-tooltip_disabled;
+		}
 	}
 }
 
@@ -31,9 +44,12 @@
 
 	position: absolute;
 	left: 0px;
-	transform: translateY( 100% );
+	transform: translate3d( 0, 100%, 0 );
 }
 
 .ck-dropdown__panel-visible {
 	display: inline-block;
+
+	/* This will prevent blurry icons in dropdown on Firefox. See #340. */
+	will-change: transform;
 }

+ 3 - 0
packages/ckeditor5-ui/theme/components/icon/icon.css

@@ -11,6 +11,9 @@ svg.ck-icon {
 	/* Inherit cursor style (#5). */
 	cursor: inherit;
 
+	/* This will prevent blurry icons on Firefox. See #340. */
+	will-change: transform;
+
 	& * {
 		/* Inherit cursor style (#5). */
 		cursor: inherit;

+ 13 - 1
packages/ckeditor5-ui/theme/components/toolbar/toolbar.css

@@ -7,6 +7,14 @@
 
 .ck-toolbar {
 	@mixin ck-unselectable;
+
+	display: flex;
+	flex-flow: row wrap;
+	align-items: center;
+
+	&.ck-toolbar_vertical {
+		flex-direction: column;
+	}
 }
 
 .ck-toolbar__separator {
@@ -15,5 +23,9 @@
 
 .ck-toolbar__newline {
 	display: block;
-	clear: left;
+	width: 100%;
+}
+
+.ck-toolbar_floating {
+	flex-wrap: nowrap;
 }