瀏覽代碼

Merge pull request #339 from ckeditor/t/333

Feature: Initial implementation of the `ButtonDropdownView`. Closes #333.

Also: 
* Allowed vertical layout of the `ToolbarView` thanks to the `#isVertical` attribute.
* Implemented `ToolbarView#className` attribute.
* Implemented `DropdownView#isEnabled` attribute along with the CSS class binding.
Aleksander Nowodzinski 8 年之前
父節點
當前提交
0ce213d17e
共有 22 個文件被更改,包括 820 次插入72 次删除
  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. 29 1
      packages/ckeditor5-ui/src/toolbar/toolbarview.js
  12. 219 0
      packages/ckeditor5-ui/tests/dropdown/button/createbuttondropdown.js
  13. 48 3
      packages/ckeditor5-ui/tests/dropdown/createdropdown.js
  14. 16 0
      packages/ckeditor5-ui/tests/dropdown/dropdownview.js
  15. 4 0
      packages/ckeditor5-ui/tests/dropdown/manual/dropdown.html
  16. 42 1
      packages/ckeditor5-ui/tests/dropdown/manual/dropdown.js
  17. 5 0
      packages/ckeditor5-ui/tests/dropdown/manual/dropdown.md
  18. 47 0
      packages/ckeditor5-ui/tests/toolbar/toolbarview.js
  19. 5 1
      packages/ckeditor5-ui/theme/components/button/button.css
  20. 16 0
      packages/ckeditor5-ui/theme/components/dropdown/buttondropdown.css
  21. 13 0
      packages/ckeditor5-ui/theme/components/dropdown/dropdown.css
  22. 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 {
 	/**

+ 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.

+ 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;
 

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

@@ -14,9 +14,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;
 		}
 	}
 

+ 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);
+		}
+	}
+}

+ 13 - 0
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;
@@ -19,6 +21,17 @@
 		top: 50%;
 		transform: translate( 0, -50% );
 	}
+
+	/* 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;
+		}
+	}
 }
 
 .ck-dropdown__panel {

+ 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;
 }