浏览代码

Merge pull request #414 from ckeditor/t/402

Feature: Implemented the `SwitchButtonView`. Closes #402. Closes #403.

Also:
* Simplified the `ListItemView` class, which is now just a container for `ButtonView` (and others),
* Moved `ListItemView#style` and `#class` to the `ButtonView` (as `#class` and `#labelStyle`),
* Enhanced the `addListToDropdown` utility with a better configuration (`ListDropdownItemDefinition`) and the support for buttons, switch buttons, and separators,
* `.ck-button` and `.ck-list` became `flex` containers; the first one to allow complex inner structures (like the toggle) and the later to take control of complex list items (like `.ck-switchbutton`).

BREAKING CHANGE: Most of the `ListItemView` functionality is now handled by the `ButtonView`,
BREAKING CHANGE: The API of the `addListToDropdown` has changed; see `ListDropdownItemDefinition` to learn more,
BREAKING CHANGE: The `.ck-button` and `.ck-list` classes are using `flex` which may have an impact on rendering.
Maciej 7 年之前
父节点
当前提交
efca62d461

+ 14 - 0
packages/ckeditor5-ui/src/button/button.jsdoc

@@ -121,6 +121,20 @@
  */
 
 /**
+ * (Optional) The additional CSS class set on the button.
+ *
+ * @observable
+ * @member {String} #class
+ */
+
+/**
+ * (Optional) The value of the `style` attribute of the label.
+ *
+ * @observable
+ * @member {String} #labelStyle
+ */
+
+/**
  * Fired when the button view is clicked. It won't be fired when the button {@link #isEnabled}
  * is `false`.
  *

+ 5 - 0
packages/ckeditor5-ui/src/button/buttonview.js

@@ -46,6 +46,8 @@ export default class ButtonView extends View {
 		const ariaLabelUid = uid();
 
 		// Implement the Button interface.
+		this.set( 'class' );
+		this.set( 'labelStyle' );
 		this.set( 'icon' );
 		this.set( 'isEnabled', true );
 		this.set( 'isOn', false );
@@ -120,6 +122,7 @@ export default class ButtonView extends View {
 				class: [
 					'ck',
 					'ck-button',
+					bind.to( 'class' ),
 					bind.if( 'isEnabled', 'ck-disabled', value => !value ),
 					bind.if( 'isVisible', 'ck-hidden', value => !value ),
 					bind.to( 'isOn', value => value ? 'ck-on' : 'ck-off' ),
@@ -201,6 +204,7 @@ export default class ButtonView extends View {
 	 */
 	_createLabelView( ariaLabelUid ) {
 		const labelView = new View();
+		const bind = this.bindTemplate;
 
 		labelView.setTemplate( {
 			tag: 'span',
@@ -210,6 +214,7 @@ export default class ButtonView extends View {
 					'ck',
 					'ck-button__label'
 				],
+				style: bind.to( 'labelStyle' ),
 				id: `ck-editor__aria-label_${ ariaLabelUid }`,
 			},
 

+ 97 - 0
packages/ckeditor5-ui/src/button/switchbuttonview.js

@@ -0,0 +1,97 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/button/switchbuttonview
+ */
+
+import View from '../view';
+import ButtonView from './buttonview';
+
+import '../../theme/components/button/switchbutton.css';
+
+/**
+ * The switch button view class.
+ *
+ *		const view = new SwitchButtonView();
+ *
+ *		view.set( {
+ *			withText: true,
+ *			label: 'Switch me!'
+ *		} );
+ *
+ *		view.render();
+ *
+ *		document.body.append( view.element );
+ *
+ * @extends module:ui/buttonview~ButtonView
+ */
+export default class SwitchButtonView extends ButtonView {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		/**
+		 * The toggle switch of the button.
+		 *
+		 * @readonly
+		 * @member {module:ui/view~View} #toggleSwitchView
+		 */
+		this.toggleSwitchView = this._createToggleView();
+
+		this.extendTemplate( {
+			attributes: {
+				class: 'ck-switchbutton'
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		this.children.add( this.toggleSwitchView );
+	}
+
+	/**
+	 * Creates a toggle child view.
+	 *
+	 * @private
+	 * @returns {module:ui/view~View}
+	 */
+	_createToggleView() {
+		const toggleSwitchView = new View();
+
+		toggleSwitchView.setTemplate( {
+			tag: 'span',
+
+			attributes: {
+				class: [
+					'ck',
+					'ck-button__toggle'
+				],
+			},
+
+			children: [
+				{
+					tag: 'span',
+
+					attributes: {
+						class: [
+							'ck',
+							'ck-button__toggle__inner'
+						],
+					}
+				}
+			]
+		} );
+
+		return toggleSwitchView;
+	}
+}

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

@@ -188,8 +188,8 @@ export default class DropdownView extends View {
 		/**
 		 * Fired when the toolbar button or list item is executed.
 		 *
-		 * For {@link #listView} It fires when one of the list items has been
-		 * {@link module:ui/list/listitemview~ListItemView#event:execute executed}.
+		 * For {@link #listView} It fires when a child of some {@link module:ui/list/listitemview~ListItemView}
+		 * fired `execute`.
 		 *
 		 * For {@link #toolbarView} It fires when one of the buttons has been
 		 * {@link module:ui/button/buttonview~ButtonView#event:execute executed}.

+ 48 - 17
packages/ckeditor5-ui/src/dropdown/utils.js

@@ -14,6 +14,8 @@ import ToolbarView from '../toolbar/toolbarview';
 import ListView from '../list/listview';
 import ListItemView from '../list/listitemview';
 import ListSeparatorView from '../list/listseparatorview';
+import ButtonView from '../button/buttonview';
+import SwitchButtonView from '../button/switchbuttonview';
 
 import clickOutsideHandler from '../bindings/clickoutsidehandler';
 
@@ -146,8 +148,15 @@ export function addToolbarToDropdown( dropdownView, buttons ) {
  *
  *		const items = new Collection();
  *
- *		items.add( new Model( { label: 'First item', style: 'color: red' } ) );
- *		items.add( new Model( { label: 'Second item', style: 'color: green', class: 'foo' } ) );
+ *		items.add( {
+ *			type: 'button',
+ *			model: new Model( { label: 'First item', labelStyle: 'color: red' } )
+ *		} );
+ *
+ *		items.add( {
+ *			 type: 'button',
+ *			 model: new Model( { label: 'Second item', labelStyle: 'color: green', class: 'foo' } )
+ * 		} );
  *
  *		const dropdown = createDropdown( locale );
  *
@@ -164,28 +173,34 @@ export function addToolbarToDropdown( dropdownView, buttons ) {
  * See {@link module:ui/dropdown/utils~createDropdown} and {@link module:list/list~List}.
  *
  * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView A dropdown instance to which `ListVIew` will be added.
- * @param {module:utils/collection~Collection} items
- * that the inner dropdown {@link module:ui/list/listview~ListView} children are created from.
- *
- * Usually, it is a collection of {@link module:ui/model~Model models}.
+ * @param {Iterable.<module:ui/dropdown/utils~ListDropdownItemDefinition>} items
+ * A collection of the list item definitions to populate the list.
  */
 export function addListToDropdown( dropdownView, items ) {
 	const locale = dropdownView.locale;
 	const listView = dropdownView.listView = new ListView( locale );
 
-	listView.items.bindTo( items ).using( itemModel => {
-		let item;
+	listView.items.bindTo( items ).using( ( { type, model } ) => {
+		if ( type === 'separator' ) {
+			return new ListSeparatorView( locale );
+		} else if ( type === 'button' || type === 'switchbutton' ) {
+			const listItemView = new ListItemView( locale );
+			let buttonView;
 
-		if ( itemModel.isSeparator ) {
-			item = new ListSeparatorView( locale );
-		} else {
-			item = new ListItemView( locale );
+			if ( type === 'button' ) {
+				buttonView = new ButtonView( locale );
+			} else {
+				buttonView = new SwitchButtonView( locale );
+			}
 
-			// Bind all attributes of the model to the item view.
-			item.bind( ...Object.keys( itemModel ) ).to( itemModel );
-		}
+			// Bind all model properties to the button view.
+			buttonView.bind( ...Object.keys( model ) ).to( model );
+			buttonView.delegate( 'execute' ).to( listItemView );
+
+			listItemView.children.add( buttonView );
 
-		return item;
+			return listItemView;
+		}
 	} );
 
 	dropdownView.panelView.children.add( listView );
@@ -223,7 +238,12 @@ function closeDropdownOnBlur( dropdownView ) {
 // @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView
 function closeDropdownOnExecute( dropdownView ) {
 	// Close the dropdown when one of the list items has been executed.
-	dropdownView.on( 'execute', () => {
+	dropdownView.on( 'execute', evt => {
+		// Toggling a switch button view should not close the dropdown.
+		if ( evt.source instanceof SwitchButtonView ) {
+			return;
+		}
+
 		dropdownView.isOpen = false;
 	} );
 }
@@ -248,3 +268,14 @@ function focusDropdownContentsOnArrows( dropdownView ) {
 		}
 	} );
 }
+
+/**
+ * A definition of the list item used by the {@link module:ui/dropdown/utils~addListToDropdown}
+ * utility.
+ *
+ * @typedef {Object} module:ui/dropdown/utils~ListDropdownItemDefinition
+ *
+ * @property {String} type Either `'separator'`, `'button'` or `'switchbutton'`.
+ * @property {module:ui/model~Model} [model] Model of the item (when **not** `'separator'`).
+ * Its properties fuel the newly created list item (or its children, depending on the `type`).
+ */

+ 9 - 105
packages/ckeditor5-ui/src/list/listitemview.js

@@ -8,7 +8,6 @@
  */
 
 import View from '../view';
-import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 
 /**
  * The list item view class.
@@ -19,27 +18,16 @@ export default class ListItemView extends View {
 	/**
 	 * @inheritDoc
 	 */
-	constructor() {
-		super();
+	constructor( locale ) {
+		super( locale );
 
 		/**
-		 * Controls the `tabindex` attribute of the item.
-		 *
-		 * @observable
-		 * @default -1
-		 * @member {String} #tabindex
-		 */
-		this.set( 'tabindex', -1 );
-
-		/**
-		 * Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 * Collection of the child views inside of the list item {@link #element}.
 		 *
 		 * @readonly
-		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
+		 * @member {module:ui/viewcollection~ViewCollection}
 		 */
-		this.keystrokes = new KeystrokeHandler();
-
-		const bind = this.bindTemplate;
+		this.children = this.createCollection();
 
 		this.setTemplate( {
 			tag: 'li',
@@ -47,102 +35,18 @@ export default class ListItemView extends View {
 			attributes: {
 				class: [
 					'ck',
-					'ck-list__item',
-					bind.to( 'class' ),
-					bind.if( 'isActive', 'ck-list__item_active' ),
-					bind.if( 'isEnabled', 'ck-disabled', value => !value )
-				],
-				style: bind.to( 'style' ),
-				tabindex: bind.to( 'tabindex' )
+					'ck-list__item'
+				]
 			},
 
-			children: [
-				{
-					text: bind.to( 'label' )
-				}
-			],
-
-			on: {
-				click: bind.to( evt => {
-					// We can't make the button disabled using the disabled attribute, because it won't be focusable.
-					// Though, shouldn't this condition be moved to the button controller?
-					if ( this.isEnabled ) {
-						this.fire( 'execute' );
-					} else {
-						// Prevent the default when button is disabled, to block e.g.
-						// automatic form submitting. See ckeditor/ckeditor5-link#74.
-						evt.preventDefault();
-					}
-				} )
-			}
+			children: this.children
 		} );
-
-		/**
-		 * (Optional) Controls whether the list item is enabled, i.e. it can be clicked and execute an action.
-		 *
-		 * @observable
-		 * @default true
-		 * @member {Boolean} #isEnabled
-		 */
-		this.set( 'isEnabled', true );
-
-		/**
-		 * The label of the list item.
-		 *
-		 * @observable
-		 * @member {String} #label
-		 */
-
-		/**
-		 * (Optional) The DOM style attribute of the list item.
-		 *
-		 * @observable
-		 * @member {String} #style
-		 */
-
-		/**
-		 * (Optional) The additional class set on the {@link #element}.
-		 *
-		 * @observable
-		 * @member {String} #class
-		 */
-
-		/**
-		 * (Optional) When set, it marks the item as active among the others.
-		 *
-		 * @observable
-		 * @member {Boolean} #isActive
-		 */
-
-		/**
-		 * Fired when the list item has been clicked.
-		 *
-		 * @event execute
-		 */
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	render() {
-		super.render();
-
-		const onKeystrokePress = ( data, cancel ) => {
-			this.fire( 'execute' );
-			cancel();
-		};
-
-		this.keystrokes.listenTo( this.element );
-
-		// Execute on Enter and Space key press.
-		this.keystrokes.set( 'Enter', onKeystrokePress );
-		this.keystrokes.set( 'Space', onKeystrokePress );
 	}
 
 	/**
 	 * Focuses the list item.
 	 */
 	focus() {
-		this.element.focus();
+		this.children.first.focus();
 	}
 }

+ 16 - 0
packages/ckeditor5-ui/tests/button/buttonview.js

@@ -97,6 +97,22 @@ describe( 'ButtonView', () => {
 				view.type = null;
 				expect( view.element.getAttribute( 'type' ) ).to.equal( 'button' );
 			} );
+
+			it( 'reacts on view#class', () => {
+				view.set( 'class', 'foo' );
+
+				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'labelView', () => {
+			it( 'reacts on view#labelStyle', () => {
+				expect( view.labelView.element.attributes.getNamedItem( 'style' ) ).to.be.null;
+
+				view.labelStyle = 'color: red';
+
+				expect( view.labelView.element.attributes.getNamedItem( 'style' ).value ).to.equal( 'color: red' );
+			} );
 		} );
 
 		describe( 'tooltip', () => {

+ 46 - 0
packages/ckeditor5-ui/tests/button/switchbuttonview.js

@@ -0,0 +1,46 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import SwitchButtonView from '../../src/button/switchbuttonview';
+import View from '../../src/view';
+
+describe( 'SwitchButtonView', () => {
+	let locale, view;
+
+	beforeEach( () => {
+		locale = { t() {} };
+
+		view = new SwitchButtonView( locale );
+		view.render();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'creates #toggleSwitchView', () => {
+			expect( view.toggleSwitchView ).to.be.instanceOf( View );
+		} );
+
+		it( 'sets CSS class', () => {
+			expect( view.element.classList.contains( 'ck-switchbutton' ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'render', () => {
+		it( 'adds #toggleSwitchView to #children', () => {
+			expect( view.children.get( 2 ) ).to.equal( view.toggleSwitchView );
+		} );
+	} );
+
+	describe( '#toggleSwitchView', () => {
+		it( 'has proper DOM structure', () => {
+			const toggleElement = view.toggleSwitchView.element;
+
+			expect( toggleElement.classList.contains( 'ck' ) ).to.be.true;
+			expect( toggleElement.classList.contains( 'ck-button__toggle' ) ).to.be.true;
+
+			expect( toggleElement.firstChild.classList.contains( 'ck' ) ).to.be.true;
+			expect( toggleElement.firstChild.classList.contains( 'ck-button__toggle__inner' ) ).to.be.true;
+		} );
+	} );
+} );

+ 139 - 40
packages/ckeditor5-ui/tests/dropdown/utils.js

@@ -12,6 +12,7 @@ import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import Model from '../../src/model';
 
 import ButtonView from '../../src/button/buttonview';
+import SwitchButtonView from '../../src/button/switchbuttonview';
 import DropdownView from '../../src/dropdown/dropdownview';
 import DropdownPanelView from '../../src/dropdown/dropdownpanelview';
 import SplitButtonView from '../../src/dropdown/button/splitbuttonview';
@@ -164,6 +165,24 @@ describe( 'utils', () => {
 					dropdownView.fire( 'execute' );
 					expect( dropdownView.isOpen ).to.be.false;
 				} );
+
+				it( 'does not change #isOpen if #execute triggered by a SwitchButtonView', () => {
+					const items = new Collection();
+
+					items.add( {
+						type: 'switchbutton',
+						model: new Model( {
+							label: 'foo'
+						} )
+					} );
+
+					addListToDropdown( dropdownView, items );
+
+					dropdownView.isOpen = true;
+
+					dropdownView.listView.items.first.children.first.fire( 'execute' );
+					expect( dropdownView.isOpen ).to.be.true;
+				} );
 			} );
 
 			describe( 'focusDropdownContentsOnArrows()', () => {
@@ -260,28 +279,28 @@ describe( 'utils', () => {
 				const panelChildren = dropdownView.panelView.children;
 
 				expect( panelChildren ).to.have.length( 1 );
-				expect( panelChildren.get( 0 ) ).to.equal( dropdownView.toolbarView );
+				expect( panelChildren.first ).to.equal( dropdownView.toolbarView );
 				expect( dropdownView.toolbarView ).to.be.instanceof( ToolbarView );
 			} );
 
 			it( 'delegates view.toolbarView.items#execute to the view', done => {
 				dropdownView.on( 'execute', evt => {
-					expect( evt.source ).to.equal( dropdownView.toolbarView.items.get( 0 ) );
-					expect( evt.path ).to.deep.equal( [ dropdownView.toolbarView.items.get( 0 ), dropdownView ] );
+					expect( evt.source ).to.equal( dropdownView.toolbarView.items.first );
+					expect( evt.path ).to.deep.equal( [ dropdownView.toolbarView.items.first, dropdownView ] );
 
 					done();
 				} );
 
-				dropdownView.toolbarView.items.get( 0 ).fire( 'execute' );
+				dropdownView.toolbarView.items.first.fire( 'execute' );
 			} );
 		} );
 	} );
 
 	describe( 'addListToDropdown()', () => {
-		let items;
+		let definitions, listItems;
 
 		beforeEach( () => {
-			items = new Collection();
+			definitions = new Collection();
 
 			dropdownView = createDropdown( locale );
 			dropdownView.buttonView.set( {
@@ -290,8 +309,9 @@ describe( 'utils', () => {
 				label: 'foo'
 			} );
 
-			addListToDropdown( dropdownView, items );
+			addListToDropdown( dropdownView, definitions );
 
+			listItems = dropdownView.listView.items;
 			dropdownView.render();
 			document.body.appendChild( dropdownView.element );
 		} );
@@ -305,58 +325,137 @@ describe( 'utils', () => {
 				const panelChildren = dropdownView.panelView.children;
 
 				expect( panelChildren ).to.have.length( 1 );
-				expect( panelChildren.get( 0 ) ).to.equal( dropdownView.listView );
+				expect( panelChildren.first ).to.equal( dropdownView.listView );
 				expect( dropdownView.listView ).to.be.instanceof( ListView );
 			} );
 
-			it( 'is bound to model#items', () => {
-				items.add( new Model( { label: 'a', style: 'b' } ) );
-				items.add( new Model( { label: 'c', style: 'd' } ) );
-
-				expect( dropdownView.listView.items ).to.have.length( 2 );
-				expect( dropdownView.listView.items.get( 0 ) ).to.be.instanceOf( ListItemView );
-				expect( dropdownView.listView.items.get( 1 ).label ).to.equal( 'c' );
-				expect( dropdownView.listView.items.get( 1 ).style ).to.equal( 'd' );
+			it( 'ignores unknown definition types', () => {
+				definitions.add( { type: 'foo' } );
 
-				items.remove( 1 );
-				expect( dropdownView.listView.items ).to.have.length( 1 );
-				expect( dropdownView.listView.items.get( 0 ).label ).to.equal( 'a' );
-				expect( dropdownView.listView.items.get( 0 ).style ).to.equal( 'b' );
+				expect( listItems.length ).to.equal( 0 );
 			} );
 
-			it( 'binds all attributes in model#items', () => {
-				const itemModel = new Model( { label: 'a', style: 'b', foo: 'bar', baz: 'qux' } );
+			describe( 'with ButtonView', () => {
+				it( 'is populated using item definitions', () => {
+					definitions.add( {
+						type: 'button',
+						model: new Model( { label: 'a', labelStyle: 'b' } )
+					} );
+
+					definitions.add( {
+						type: 'button',
+						model: new Model( { label: 'c', labelStyle: 'd' } )
+					} );
+
+					expect( listItems ).to.have.length( 2 );
+					expect( listItems.first ).to.be.instanceOf( ListItemView );
+					expect( listItems.first.children.first ).to.be.instanceOf( ButtonView );
+
+					expect( listItems.get( 1 ).children.first.label ).to.equal( 'c' );
+					expect( listItems.get( 1 ).children.first.labelStyle ).to.equal( 'd' );
+
+					definitions.remove( 1 );
+					expect( listItems ).to.have.length( 1 );
+					expect( listItems.first.children.first.label ).to.equal( 'a' );
+					expect( listItems.first.children.first.labelStyle ).to.equal( 'b' );
+				} );
+
+				it( 'binds all button properties', () => {
+					const def = {
+						type: 'button',
+						model: new Model( { label: 'a', labelStyle: 'b', foo: 'bar', baz: 'qux' } )
+					};
+
+					definitions.add( def );
+
+					const button = listItems.first.children.first;
+
+					expect( button.foo ).to.equal( 'bar' );
+					expect( button.baz ).to.equal( 'qux' );
+
+					def.model.baz = 'foo?';
+					expect( button.baz ).to.equal( 'foo?' );
+				} );
+
+				it( 'delegates ButtonView#execute to the ListItemView', done => {
+					definitions.add( {
+						type: 'button',
+						model: new Model( { label: 'a', labelStyle: 'b' } )
+					} );
 
-				items.add( itemModel );
+					const listItem = listItems.first;
+					const button = listItem.children.first;
 
-				const item = dropdownView.listView.items.get( 0 );
+					dropdownView.on( 'execute', evt => {
+						expect( evt.source ).to.equal( button );
+						expect( evt.path ).to.deep.equal( [ button, listItem, dropdownView ] );
 
-				expect( item.foo ).to.equal( 'bar' );
-				expect( item.baz ).to.equal( 'qux' );
+						done();
+					} );
 
-				itemModel.baz = 'foo?';
-				expect( item.baz ).to.equal( 'foo?' );
+					button.fire( 'execute' );
+				} );
 			} );
 
-			it( 'delegates view.listView#execute to the view', done => {
-				items.add( new Model( { label: 'a', style: 'b' } ) );
+			describe( 'with SwitchButtonView', () => {
+				it( 'is populated using item definitions', () => {
+					definitions.add( {
+						type: 'switchbutton',
+						model: new Model( { label: 'a', labelStyle: 'b' } )
+					} );
 
-				dropdownView.on( 'execute', evt => {
-					expect( evt.source ).to.equal( dropdownView.listView.items.get( 0 ) );
-					expect( evt.path ).to.deep.equal( [ dropdownView.listView.items.get( 0 ), dropdownView ] );
+					expect( listItems ).to.have.length( 1 );
+					expect( listItems.first ).to.be.instanceOf( ListItemView );
+					expect( listItems.first.children.first ).to.be.instanceOf( SwitchButtonView );
 
-					done();
+					expect( listItems ).to.have.length( 1 );
+					expect( listItems.first.children.first.label ).to.equal( 'a' );
+					expect( listItems.first.children.first.labelStyle ).to.equal( 'b' );
 				} );
 
-				dropdownView.listView.items.get( 0 ).fire( 'execute' );
-			} );
+				it( 'binds all button properties', () => {
+					const def = {
+						type: 'switchbutton',
+						model: new Model( { label: 'a', labelStyle: 'b', foo: 'bar', baz: 'qux' } )
+					};
 
-			it( 'is filled with separators', () => {
-				const itemModel = new Model( { isSeparator: true } );
+					definitions.add( def );
+
+					const button = listItems.first.children.first;
+
+					expect( button.foo ).to.equal( 'bar' );
+					expect( button.baz ).to.equal( 'qux' );
+
+					def.model.baz = 'foo?';
+					expect( button.baz ).to.equal( 'foo?' );
+				} );
 
-				items.add( itemModel );
+				it( 'delegates SwitchButtonView#execute to the ListItemView', done => {
+					definitions.add( {
+						type: 'switchbutton',
+						model: new Model( { label: 'a', labelStyle: 'b' } )
+					} );
 
-				expect( dropdownView.listView.items.get( 0 ) ).to.be.instanceOf( ListSeparatorView );
+					const listItem = listItems.first;
+					const button = listItem.children.first;
+
+					dropdownView.on( 'execute', evt => {
+						expect( evt.source ).to.equal( button );
+						expect( evt.path ).to.deep.equal( [ button, listItem, dropdownView ] );
+
+						done();
+					} );
+
+					button.fire( 'execute' );
+				} );
+			} );
+
+			describe( 'with ListSeparatorView', () => {
+				it( 'creates a separator from the definition', () => {
+					definitions.add( { type: 'separator' } );
+
+					expect( listItems.first ).to.be.instanceOf( ListSeparatorView );
+				} );
 			} );
 		} );
 	} );

+ 9 - 140
packages/ckeditor5-ui/tests/list/listitemview.js

@@ -3,20 +3,15 @@
  * For licensing, see LICENSE.md.
  */
 
-/* global Event */
-
+import ButtonView from '../../src/button/buttonview';
 import ListItemView from '../../src/list/listitemview';
-import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import ViewCollection from '../../src/viewcollection';
 
 describe( 'ListItemView', () => {
 	let view;
 
 	beforeEach( () => {
 		view = new ListItemView();
-		view.set( {
-			style: 'foo',
-			label: 'bar'
-		} );
 
 		return view.render();
 	} );
@@ -27,145 +22,19 @@ describe( 'ListItemView', () => {
 			expect( view.element.classList.contains( 'ck-list__item' ) ).to.be.true;
 		} );
 
-		it( 'should create #keystrokes instance', () => {
-			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
-		} );
-	} );
-
-	describe( 'render()', () => {
-		it( 'starts listening for #keystrokes coming from #element', () => {
-			view = new ListItemView();
-
-			const spy = sinon.spy( view.keystrokes, 'listenTo' );
-
-			view.render();
-			sinon.assert.calledOnce( spy );
-			sinon.assert.calledWithExactly( spy, view.element );
-		} );
-
-		// https://github.com/ckeditor/ckeditor5-ui/issues/153
-		it( 'triggers view#execute event when Enter or Space key is pressed', () => {
-			const spy = sinon.spy();
-			const evtData = {
-				keyCode: 10,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			};
-
-			view.on( 'execute', spy );
-			view.keystrokes.press( evtData );
-
-			sinon.assert.notCalled( spy );
-			sinon.assert.notCalled( evtData.preventDefault );
-
-			evtData.keyCode = 13;
-			view.keystrokes.press( evtData );
-
-			sinon.assert.calledOnce( spy );
-			sinon.assert.calledOnce( evtData.preventDefault );
-
-			evtData.keyCode = 32;
-			view.keystrokes.press( evtData );
-
-			sinon.assert.calledTwice( spy );
-			sinon.assert.calledTwice( evtData.preventDefault );
-		} );
-	} );
-
-	describe( 'DOM bindings', () => {
-		describe( '"class" attribute', () => {
-			it( 'reacts on view#class', () => {
-				expect( view.element.classList ).to.have.length( 2 );
-
-				view.set( 'class', 'foo' );
-
-				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
-			} );
-
-			it( 'reacts on view#isActive', () => {
-				expect( view.element.classList ).to.have.length( 2 );
-
-				view.set( 'isActive', true );
-
-				expect( view.element.classList.contains( 'ck-list__item_active' ) ).to.be.true;
-			} );
-
-			it( 'reacts on view#isEnabled', () => {
-				expect( view.element.classList ).to.have.length( 2 );
-
-				expect( view.element.classList.contains( 'ck-disabled' ) ).to.be.false;
-
-				view.set( 'isEnabled', false );
-
-				expect( view.element.classList.contains( 'ck-disabled' ) ).to.be.true;
-
-				view.set( 'isEnabled', true );
-
-				expect( view.element.classList.contains( 'ck-disabled' ) ).to.be.false;
-			} );
-		} );
-
-		describe( '"style" attribute', () => {
-			it( 'reacts on view#style', () => {
-				expect( view.element.attributes.getNamedItem( 'style' ).value ).to.equal( 'foo' );
-
-				view.style = 'color: red';
-
-				expect( view.element.attributes.getNamedItem( 'style' ).value ).to.equal( 'color: red' );
-			} );
-		} );
-
-		describe( 'text content', () => {
-			it( 'reacts on view#label', () => {
-				expect( view.element.innerHTML ).to.equal( 'bar' );
-
-				view.label = 'baz';
-
-				expect( view.element.innerHTML ).to.equal( 'baz' );
-			} );
-		} );
-
-		describe( 'tabindex', () => {
-			it( 'is initially set ', () => {
-				expect( view.element.attributes.tabindex.value ).to.equal( '-1' );
-			} );
-
-			it( 'reacts on view#tabindex', () => {
-				view.tabindex = 3;
-
-				expect( view.element.attributes.tabindex.value ).to.equal( '3' );
-			} );
-		} );
-
-		describe( 'view#execute event', () => {
-			it( 'triggers view#execute event when "click" is fired in DOM', () => {
-				const spy = sinon.spy();
-
-				view.on( 'execute', spy );
-
-				view.element.dispatchEvent( new Event( 'click' ) );
-				expect( spy.calledOnce ).to.be.true;
-			} );
-
-			it( 'does not triggers view#execute event when "click" is fired in DOM and isEnables=false', () => {
-				const spy = sinon.spy();
-
-				view.on( 'execute', spy );
-
-				view.isEnabled = false;
-
-				view.element.dispatchEvent( new Event( 'click' ) );
-				sinon.assert.notCalled( spy );
-			} );
+		it( 'creates view#children collection', () => {
+			expect( view.children ).to.be.instanceOf( ViewCollection );
 		} );
 	} );
 
 	describe( 'focus()', () => {
-		it( 'focuses the item in DOM', () => {
-			const spy = sinon.spy( view.element, 'focus' );
+		it( 'focuses the first child item', () => {
+			const button = new ButtonView();
+			view.children.add( button );
 
-			view.focus();
+			const spy = sinon.spy( button.element, 'focus' );
 
+			view.focus();
 			sinon.assert.calledOnce( spy );
 		} );
 	} );

+ 8 - 0
packages/ckeditor5-ui/theme/components/button/button.css

@@ -12,6 +12,9 @@ a.ck.ck-button {
 	@mixin ck-tooltip_enabled;
 
 	position: relative;
+	display: inline-flex;
+	align-items: center;
+	justify-content: left;
 
 	&.ck-button_with-text {
 		& .ck-button__label {
@@ -19,6 +22,11 @@ a.ck.ck-button {
 		}
 	}
 
+	/* Center the icon horizontally in a button without text. */
+	&:not(.ck-button_with-text)  {
+		justify-content: center;
+	}
+
 	&:hover {
 		@mixin ck-tooltip_visible;
 	}

+ 14 - 0
packages/ckeditor5-ui/theme/components/button/switchbutton.css

@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck.ck-button.ck-switchbutton {
+	& .ck-button__toggle {
+		display: block;
+
+		& .ck-button__toggle__inner {
+			display: block;
+		}
+	}
+}

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

@@ -12,10 +12,6 @@
 	& .ck-dropdown__arrow {
 		pointer-events: none;
 		z-index: var(--ck-z-default);
-
-		position: absolute;
-		top: 50%;
-		transform: translate3d( 0, -50%, 0 );
 	}
 
 	/* Dropdown button should span horizontally, e.g. in vertical toolbars */

+ 2 - 0
packages/ckeditor5-ui/theme/components/list/list.css

@@ -10,6 +10,8 @@
 
 	/* Crop the the items when the list has huge border-radius. */
 	overflow: hidden;
+	display: flex;
+	flex-direction: column;
 
 	& .ck-list__item,
 	& .ck-list__separator {