Przeglądaj źródła

Merge pull request #523 from ckeditor/t/ckeditor5/416d

Feature: Implemented automatic items grouping in the `ToolbarView` component. Closes ckeditor/ckeditor5#416.

BREAKING CHANGE: The internal structure of the component has changed. Toolbar items are no longer direct descendants of the toolbar in DOM, which may affect some integrations (mainly CSS selectors if adjustments were made to the styles).
Maciej 6 lat temu
rodzic
commit
61a294a8f5

+ 643 - 18
packages/ckeditor5-ui/src/toolbar/toolbarview.js

@@ -14,10 +14,15 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
 import FocusCycler from '../focuscycler';
 import FocusCycler from '../focuscycler';
 import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 import ToolbarSeparatorView from './toolbarseparatorview';
 import ToolbarSeparatorView from './toolbarseparatorview';
+import getResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/getresizeobserver';
 import preventDefault from '../bindings/preventdefault.js';
 import preventDefault from '../bindings/preventdefault.js';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import { createDropdown, addToolbarToDropdown } from '../dropdown/utils';
+import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import verticalDotsIcon from '@ckeditor/ckeditor5-core/theme/icons/three-vertical-dots.svg';
 
 
 import '../../theme/components/toolbar/toolbar.css';
 import '../../theme/components/toolbar/toolbar.css';
-import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 /**
 /**
  * The toolbar view class.
  * The toolbar view class.
@@ -32,13 +37,22 @@ export default class ToolbarView extends View {
 	 * Also see {@link #render}.
 	 * Also see {@link #render}.
 	 *
 	 *
 	 * @param {module:utils/locale~Locale} locale The localization services instance.
 	 * @param {module:utils/locale~Locale} locale The localization services instance.
+	 * @param {module:ui/toolbar/toolbarview~ToolbarOptions} [options] Configuration options of the toolbar.
 	 */
 	 */
-	constructor( locale ) {
+	constructor( locale, options ) {
 		super( locale );
 		super( locale );
 
 
 		const bind = this.bindTemplate;
 		const bind = this.bindTemplate;
 		const t = this.t;
 		const t = this.t;
 
 
+		/**
+		 * A reference to the options object passed to the constructor.
+		 *
+		 * @readonly
+		 * @member {module:ui/toolbar/toolbarview~ToolbarOptions}
+		 */
+		this.options = options || {};
+
 		/**
 		/**
 		 * Label used by assistive technologies to describe this toolbar element.
 		 * Label used by assistive technologies to describe this toolbar element.
 		 *
 		 *
@@ -48,7 +62,7 @@ export default class ToolbarView extends View {
 		this.set( 'ariaLabel', t( 'Editor toolbar' ) );
 		this.set( 'ariaLabel', t( 'Editor toolbar' ) );
 
 
 		/**
 		/**
-		 * Collection of the toolbar items (like buttons).
+		 * Collection of the toolbar items (buttons, drop–downs, etc.).
 		 *
 		 *
 		 * @readonly
 		 * @readonly
 		 * @member {module:ui/viewcollection~ViewCollection}
 		 * @member {module:ui/viewcollection~ViewCollection}
@@ -56,7 +70,7 @@ export default class ToolbarView extends View {
 		this.items = this.createCollection();
 		this.items = this.createCollection();
 
 
 		/**
 		/**
-		 * Tracks information about DOM focus in the list.
+		 * Tracks information about DOM focus in the toolbar.
 		 *
 		 *
 		 * @readonly
 		 * @readonly
 		 * @member {module:utils/focustracker~FocusTracker}
 		 * @member {module:utils/focustracker~FocusTracker}
@@ -64,7 +78,8 @@ export default class ToolbarView extends View {
 		this.focusTracker = new FocusTracker();
 		this.focusTracker = new FocusTracker();
 
 
 		/**
 		/**
-		 * Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 * Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}
+		 * to handle keyboard navigation in the toolbar.
 		 *
 		 *
 		 * @readonly
 		 * @readonly
 		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
 		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
@@ -72,30 +87,70 @@ export default class ToolbarView extends View {
 		this.keystrokes = new KeystrokeHandler();
 		this.keystrokes = new KeystrokeHandler();
 
 
 		/**
 		/**
-		 * Controls the orientation of toolbar items.
+		 * An additional CSS class added to the {@link #element}.
 		 *
 		 *
 		 * @observable
 		 * @observable
-		 * @member {Boolean} #isVertical
+		 * @member {String} #class
 		 */
 		 */
-		this.set( 'isVertical', false );
+		this.set( 'class' );
 
 
 		/**
 		/**
-		 * An additional CSS class added to the {@link #element}.
+		 * A (child) view containing {@link #items toolbar items}.
+		 *
+		 * @readonly
+		 * @member {module:ui/toolbar/toolbarview~ItemsView}
+		 */
+		this.itemsView = new ItemsView( locale );
+
+		/**
+		 * A top–level collection aggregating building blocks of the toolbar.
+		 *
+		 *	┌───────────────── ToolbarView ─────────────────┐
+		 *	| ┌──────────────── #children ────────────────┐ |
+		 *	| |   ┌──────────── #itemsView ───────────┐   | |
+		 *	| |   | [ item1 ] [ item2 ] ... [ itemN ] |   | |
+		 *	| |   └──────────────────────────────────-┘   | |
+		 *	| └───────────────────────────────────────────┘ |
+		 *	└───────────────────────────────────────────────┘
+		 *
+		 * By default, it contains the {@link #itemsView} but it can be extended with additional
+		 * UI elements when necessary.
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.children = this.createCollection();
+		this.children.add( this.itemsView );
+
+		/**
+		 * A collection of {@link #items} that take part in the focus cycling
+		 * (i.e. navigation using the keyboard). Usually, it contains a subset of {@link #items} with
+		 * some optional UI elements that also belong to the toolbar and should be focusable
+		 * by the user.
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.focusables = this.createCollection();
+
+		/**
+		 * Controls the orientation of toolbar items. Only available when
+		 * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull dynamic items grouping}
+		 * is **disabled**.
 		 *
 		 *
 		 * @observable
 		 * @observable
-		 * @member {String} #class
+		 * @member {Boolean} #isVertical
 		 */
 		 */
-		this.set( 'class' );
 
 
 		/**
 		/**
-		 * Helps cycling over focusable {@link #items} in the toolbar.
+		 * Helps cycling over {@link #focusables focusable items} in the toolbar.
 		 *
 		 *
 		 * @readonly
 		 * @readonly
 		 * @protected
 		 * @protected
 		 * @member {module:ui/focuscycler~FocusCycler}
 		 * @member {module:ui/focuscycler~FocusCycler}
 		 */
 		 */
 		this._focusCycler = new FocusCycler( {
 		this._focusCycler = new FocusCycler( {
-			focusables: this.items,
+			focusables: this.focusables,
 			focusTracker: this.focusTracker,
 			focusTracker: this.focusTracker,
 			keystrokeHandler: this.keystrokes,
 			keystrokeHandler: this.keystrokes,
 			actions: {
 			actions: {
@@ -113,20 +168,30 @@ export default class ToolbarView extends View {
 				class: [
 				class: [
 					'ck',
 					'ck',
 					'ck-toolbar',
 					'ck-toolbar',
-					bind.if( 'isVertical', 'ck-toolbar_vertical' ),
 					bind.to( 'class' )
 					bind.to( 'class' )
 				],
 				],
 				role: 'toolbar',
 				role: 'toolbar',
 				'aria-label': bind.to( 'ariaLabel' )
 				'aria-label': bind.to( 'ariaLabel' )
 			},
 			},
 
 
-			children: this.items,
+			children: this.children,
 
 
 			on: {
 			on: {
 				// https://github.com/ckeditor/ckeditor5-ui/issues/206
 				// https://github.com/ckeditor/ckeditor5-ui/issues/206
 				mousedown: preventDefault( this )
 				mousedown: preventDefault( this )
 			}
 			}
 		} );
 		} );
+
+		/**
+		 * An instance of the active toolbar behavior that shapes its look and functionality.
+		 *
+		 * See {@link module:ui/toolbar/toolbarview~ToolbarBehavior} to learn more.
+		 *
+		 * @protected
+		 * @readonly
+		 * @member {module:ui/toolbar/toolbarview~ToolbarBehavior}
+		 */
+		this._behavior = this.options.shouldGroupWhenFull ? new DynamicGrouping( this ) : new StaticLayout( this );
 	}
 	}
 
 
 	/**
 	/**
@@ -135,7 +200,7 @@ export default class ToolbarView extends View {
 	render() {
 	render() {
 		super.render();
 		super.render();
 
 
-		// Items added before rendering should be known to the #focusTracker.
+		// Children added before rendering should be known to the #focusTracker.
 		for ( const item of this.items ) {
 		for ( const item of this.items ) {
 			this.focusTracker.add( item.element );
 			this.focusTracker.add( item.element );
 		}
 		}
@@ -150,17 +215,28 @@ export default class ToolbarView extends View {
 
 
 		// Start listening for the keystrokes coming from #element.
 		// Start listening for the keystrokes coming from #element.
 		this.keystrokes.listenTo( this.element );
 		this.keystrokes.listenTo( this.element );
+
+		this._behavior.render( this );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		this._behavior.destroy();
+
+		return super.destroy();
 	}
 	}
 
 
 	/**
 	/**
-	 * Focuses the first focusable in {@link #items}.
+	 * Focuses the first focusable in {@link #focusables}.
 	 */
 	 */
 	focus() {
 	focus() {
 		this._focusCycler.focusFirst();
 		this._focusCycler.focusFirst();
 	}
 	}
 
 
 	/**
 	/**
-	 * Focuses the last focusable in {@link #items}.
+	 * Focuses the last focusable in {@link #focusables}.
 	 */
 	 */
 	focusLast() {
 	focusLast() {
 		this._focusCycler.focusLast();
 		this._focusCycler.focusLast();
@@ -204,3 +280,552 @@ export default class ToolbarView extends View {
 	}
 	}
 }
 }
 
 
+/**
+ * An inner block of the {@link module:ui/toolbar/toolbarview~ToolbarView} hosting its
+ * {@link module:ui/toolbar/toolbarview~ToolbarView#items}.
+ *
+ * @private
+ * @extends module:ui/view~View
+ */
+class ItemsView extends View {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		/**
+		 * Collection of the items (buttons, drop–downs, etc.).
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.children = this.createCollection();
+
+		this.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-toolbar__items'
+				],
+			},
+			children: this.children
+		} );
+	}
+}
+
+/**
+ * A toolbar behavior that makes it static and unresponsive to the changes of the environment.
+ * At the same time, it also makes it possible to display a toolbar with a vertical layout
+ * using the {@link module:ui/toolbar/toolbarview~ToolbarView#isVertical} property.
+ *
+ * @private
+ * @implements module:ui/toolbar/toolbarview~ToolbarBehavior
+ */
+class StaticLayout {
+	/**
+	 * Creates an instance of the {@link module:ui/toolbar/toolbarview~StaticLayout} toolbar
+	 * behavior.
+	 *
+	 * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior
+	 * is added to.
+	 */
+	constructor( view ) {
+		const bind = view.bindTemplate;
+
+		// Static toolbar can be vertical when needed.
+		view.set( 'isVertical', false );
+
+		// 1:1 pass–through binding, all ToolbarView#items are visible.
+		view.itemsView.children.bindTo( view.items ).using( item => item );
+
+		// 1:1 pass–through binding, all ToolbarView#items are focusable.
+		view.focusables.bindTo( view.items ).using( item => item );
+
+		view.extendTemplate( {
+			attributes: {
+				class: [
+					// When vertical, the toolbar has an additional CSS class.
+					bind.if( 'isVertical', 'ck-toolbar_vertical' )
+				]
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {}
+}
+
+/**
+ * A toolbar behavior that makes its items respond to the changes in the geometry.
+ *
+ * In a nutshell, it groups {@link module:ui/toolbar/toolbarview~ToolbarView#items}
+ * that do not fit into visually into a single row of the toolbar (due to limited space).
+ * Items that do not fit are aggregated in a dropdown displayed at the end of the toolbar.
+ *
+ *	┌──────────────────────────────────────── ToolbarView ──────────────────────────────────────────┐
+ *	| ┌─────────────────────────────────────── #children ─────────────────────────────────────────┐ |
+ *	| |   ┌─────── #itemsView ────────┐ ┌──────────────────────┐ ┌── #groupedItemsDropdown ───┐   | |
+ *	| |   |       #ungroupedItems     | | ToolbarSeparatorView | |        #groupedItems       |   | |
+ *	| |   └──────────────────────────-┘ └──────────────────────┘ └────────────────────────────┘   | |
+ *	| |                                  \---------- only when toolbar items overflow --------/    | |
+ *	| └───────────────────────────────────────────────────────────────────────────────────────────┘ |
+ *	└───────────────────────────────────────────────────────────────────────────────────────────────┘
+ *
+ * @private
+ * @implements module:ui/toolbar/toolbarview~ToolbarBehavior
+ */
+class DynamicGrouping {
+	/**
+	 * Creates an instance of the {@link module:ui/toolbar/toolbarview~DynamicGrouping} toolbar
+	 * behavior.
+	 *
+	 * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior
+	 * is added to.
+	 */
+	constructor( view ) {
+		/**
+		 * Collection of toolbar children.
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.viewChildren = view.children;
+
+		/**
+		 * Collection of toolbar focusable elements.
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.viewFocusables = view.focusables;
+
+		/**
+		 * Collection of toolbar focusable elements.
+		 *
+		 * @readonly
+		 * @member {module:ui/toolbar/toolbarview~ItemsView}
+		 */
+		this.viewItemsView = view.itemsView;
+
+		/**
+		 * Focus tracker of the toolbar.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.viewFocusTracker = view.focusTracker;
+
+		/**
+		 * Locale of the toolbar.
+		 *
+		 * @readonly
+		 * @member {module:utils/locale~Locale}
+		 */
+		this.viewLocale = view.locale;
+
+		/**
+		 * Element of the toolbar.
+		 *
+		 * @readonly
+		 * @member {HTMLElement} #viewElement
+		 */
+
+		/**
+		 * A subset of of toolbar {@link module:ui/toolbar/toolbarview~ToolbarView#items}.
+		 * Aggregates items that fit into a single row of the toolbar and were not {@link #groupedItems grouped}
+		 * into a {@link #groupedItemsDropdown dropdown}. Items of this collection are displayed in the
+		 * {@link module:ui/toolbar/toolbarview~ToolbarView#itemsView}.
+		 *
+		 * When none of the {@link module:ui/toolbar/toolbarview~ToolbarView#items} were grouped, it
+		 * matches the {@link module:ui/toolbar/toolbarview~ToolbarView#items} collection in size and order.
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.ungroupedItems = view.createCollection();
+
+		/**
+		 * A subset of of toolbar {@link module:ui/toolbar/toolbarview~ToolbarView#items}.
+		 * A collection of the toolbar items that do not fit into a single row of the toolbar.
+		 * Grouped items are displayed in a dedicated {@link #groupedItemsDropdown dropdown}.
+		 *
+		 * When none of the {@link module:ui/toolbar/toolbarview~ToolbarView#items} were grouped,
+		 * this collection is empty.
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.groupedItems = view.createCollection();
+
+		/**
+		 * The dropdown that aggregates {@link #groupedItems grouped items} that do not fit into a single
+		 * row of the toolbar. It is displayed on demand as the last of
+		 * {@link module:ui/toolbar/toolbarview~ToolbarView#children toolbar children} and offers another
+		 * (nested) toolbar which displays items that would normally overflow.
+		 *
+		 * @readonly
+		 * @member {module:ui/dropdown/dropdownview~DropdownView}
+		 */
+		this.groupedItemsDropdown = this._createGroupedItemsDropdown();
+
+		/**
+		 * An instance of the resize observer that helps dynamically determine the geometry of the toolbar
+		 * and manage items that do not fit into a single row.
+		 *
+		 * **Note:** Created in {@link #_enableGroupingOnResize}.
+		 *
+		 * @readonly
+		 * @member {module:utils/dom/getresizeobserver~ResizeObserver}
+		 */
+		this.resizeObserver = null;
+
+		/**
+		 * A cached value of the horizontal padding style used by {@link #_updateGrouping}
+		 * to manage the {@link module:ui/toolbar/toolbarview~ToolbarView#items} that do not fit into
+		 * a single toolbar line. This value can be reused between updates because it is unlikely that
+		 * the padding will change and re–using `Window.getComputedStyle()` is expensive.
+		 *
+		 * @readonly
+		 * @member {Number}
+		 */
+		this.cachedPadding = null;
+
+		// Only those items that were not grouped are visible to the user.
+		view.itemsView.children.bindTo( this.ungroupedItems ).using( item => item );
+
+		// Make sure all #items visible in the main space of the toolbar are "focuscycleable".
+		this.ungroupedItems.on( 'add', this._updateFocusCycleableItems.bind( this ) );
+		this.ungroupedItems.on( 'remove', this._updateFocusCycleableItems.bind( this ) );
+
+		// Make sure the #groupedItemsDropdown is also included in cycling when it appears.
+		view.children.on( 'add', this._updateFocusCycleableItems.bind( this ) );
+		view.children.on( 'remove', this._updateFocusCycleableItems.bind( this ) );
+
+		// ToolbarView#items is dynamic. When an item is added, it should be automatically
+		// represented in either grouped or ungrouped items at the right index.
+		// In other words #items == concat( #ungroupedItems, #groupedItems )
+		// (in length and order).
+		view.items.on( 'add', ( evt, item, index ) => {
+			if ( index > this.ungroupedItems.length ) {
+				this.groupedItems.add( item, index - this.ungroupedItems.length );
+			} else {
+				this.ungroupedItems.add( item, index );
+			}
+
+			// When a new ungrouped item joins in and lands in #ungroupedItems, there's a chance it causes
+			// the toolbar to overflow.
+			this._updateGrouping();
+		} );
+
+		// When an item is removed from ToolbarView#items, it should be automatically
+		// removed from either grouped or ungrouped items.
+		view.items.on( 'remove', ( evt, item, index ) => {
+			if ( index > this.ungroupedItems.length ) {
+				this.groupedItems.remove( item );
+			} else {
+				this.ungroupedItems.remove( item );
+			}
+
+			// Whether removed from grouped or ungrouped items, there is a chance
+			// some new space is available and we could do some ungrouping.
+			this._updateGrouping();
+		} );
+
+		view.extendTemplate( {
+			attributes: {
+				class: [
+					// To group items dynamically, the toolbar needs a dedicated CSS class.
+					'ck-toolbar_grouping'
+				]
+			}
+		} );
+	}
+
+	/**
+	 * Enables dynamic items grouping based on the dimensions of the toolbar.
+	 *
+	 * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior
+	 * is added to.
+	 */
+	render( view ) {
+		this.viewElement = view.element;
+
+		this._enableGroupingOnResize();
+	}
+
+	/**
+	 * Cleans up the internals used by this behavior.
+	 */
+	destroy() {
+		// The dropdown may not be in ToolbarView#children at the moment of toolbar destruction
+		// so let's make sure it's actually destroyed along with the toolbar.
+		this.groupedItemsDropdown.destroy();
+
+		this.resizeObserver.disconnect();
+	}
+
+	/**
+	 * When called, it will check if any of the {@link #ungroupedItems} do not fit into a single row of the toolbar,
+	 * and it will move them to the {@link #groupedItems} when it happens.
+	 *
+	 * At the same time, it will also check if there is enough space in the toolbar for the first of the
+	 * {@link #groupedItems} to be returned back to {@link #ungroupedItems} and still fit into a single row
+	 * without the toolbar wrapping.
+	 *
+	 * @protected
+	 */
+	_updateGrouping() {
+		// Do no grouping–related geometry analysis when the toolbar is detached from visible DOM,
+		// for instance before #render(), or after render but without a parent or a parent detached
+		// from DOM. DOMRects won't work anyway and there will be tons of warning in the console and
+		// nothing else.
+		if ( !this.viewElement.ownerDocument.body.contains( this.viewElement ) ) {
+			return;
+		}
+
+		let wereItemsGrouped;
+
+		// Group #items as long as some wrap to the next row. This will happen, for instance,
+		// when the toolbar is getting narrow and there is not enough space to display all items in
+		// a single row.
+		while ( this._areItemsOverflowing ) {
+			this._groupLastItem();
+
+			wereItemsGrouped = true;
+		}
+
+		// If none were grouped now but there were some items already grouped before,
+		// then, what the hell, maybe let's see if some of them can be ungrouped. This happens when,
+		// for instance, the toolbar is stretching and there's more space in it than before.
+		if ( !wereItemsGrouped && this.groupedItems.length ) {
+			// Ungroup items as long as none are overflowing or there are none to ungroup left.
+			while ( this.groupedItems.length && !this._areItemsOverflowing ) {
+				this._ungroupFirstItem();
+			}
+
+			// If the ungrouping ended up with some item wrapping to the next row,
+			// put it back to the group toolbar ("undo the last ungroup"). We don't know whether
+			// an item will wrap or not until we ungroup it (that's a DOM/CSS thing) so this
+			// clean–up is vital for the algorithm.
+			if ( this._areItemsOverflowing ) {
+				this._groupLastItem();
+			}
+		}
+	}
+
+	/**
+	 * Returns `true` when {@link module:ui/toolbar/toolbarview~ToolbarView#element} children visually overflow,
+	 * for instance if the toolbar is narrower than its members. `false` otherwise.
+	 *
+	 * @private
+	 * @type {Boolean}
+	 */
+	get _areItemsOverflowing() {
+		// An empty toolbar cannot overflow.
+		if ( !this.ungroupedItems.length ) {
+			return false;
+		}
+
+		const element = this.viewElement;
+		const uiLanguageDirection = this.viewLocale.uiLanguageDirection;
+		const lastChildRect = new Rect( element.lastChild );
+		const toolbarRect = new Rect( element );
+
+		if ( !this.cachedPadding ) {
+			const computedStyle = global.window.getComputedStyle( element );
+			const paddingProperty = uiLanguageDirection === 'ltr' ? 'paddingRight' : 'paddingLeft';
+
+			// parseInt() is essential because of quirky floating point numbers logic and DOM.
+			// If the padding turned out too big because of that, the grouped items dropdown would
+			// always look (from the Rect perspective) like it overflows (while it's not).
+			this.cachedPadding = Number.parseInt( computedStyle[ paddingProperty ] );
+		}
+
+		if ( uiLanguageDirection === 'ltr' ) {
+			return lastChildRect.right > toolbarRect.right - this.cachedPadding;
+		} else {
+			return lastChildRect.left < toolbarRect.left + this.cachedPadding;
+		}
+	}
+
+	/**
+	 * Enables the functionality that prevents {@link #ungroupedItems} from overflowing (wrapping to the next row)
+	 * upon resize when there is little space available. Instead, the toolbar items are moved to the
+	 * {@link #groupedItems} collection and displayed in a dropdown at the end of the row (which has its own nested toolbar).
+	 *
+	 * When called, the toolbar will automatically analyze the location of its {@link #ungroupedItems} and "group"
+	 * them in the dropdown if necessary. It will also observe the browser window for size changes in
+	 * the future and respond to them by grouping more items or reverting already grouped back, depending
+	 * on the visual space available.
+	 *
+	 * @private
+	 */
+	_enableGroupingOnResize() {
+		let previousWidth;
+
+		// TODO: Consider debounce.
+		this.resizeObserver = getResizeObserver( ( [ entry ] ) => {
+			if ( !previousWidth || previousWidth !== entry.contentRect.width ) {
+				this._updateGrouping();
+
+				previousWidth = entry.contentRect.width;
+			}
+		} );
+
+		this.resizeObserver.observe( this.viewElement );
+
+		this._updateGrouping();
+	}
+
+	/**
+	 * When called, it will remove the last item from {@link #ungroupedItems} and move it back
+	 * to the {@link #groupedItems} collection.
+	 *
+	 * The opposite of {@link #_ungroupFirstItem}.
+	 *
+	 * @private
+	 */
+	_groupLastItem() {
+		if ( !this.groupedItems.length ) {
+			this.viewChildren.add( new ToolbarSeparatorView() );
+			this.viewChildren.add( this.groupedItemsDropdown );
+			this.viewFocusTracker.add( this.groupedItemsDropdown.element );
+		}
+
+		this.groupedItems.add( this.ungroupedItems.remove( this.ungroupedItems.last ), 0 );
+	}
+
+	/**
+	 * Moves the very first item belonging to {@link #groupedItems} back
+	 * to the {@link #ungroupedItems} collection.
+	 *
+	 * The opposite of {@link #_groupLastItem}.
+	 *
+	 * @private
+	 */
+	_ungroupFirstItem() {
+		this.ungroupedItems.add( this.groupedItems.remove( this.groupedItems.first ) );
+
+		if ( !this.groupedItems.length ) {
+			this.viewChildren.remove( this.groupedItemsDropdown );
+			this.viewChildren.remove( this.viewChildren.last );
+			this.viewFocusTracker.remove( this.groupedItemsDropdown.element );
+		}
+	}
+
+	/**
+	 * Creates the {@link #groupedItemsDropdown} that hosts the members of the {@link #groupedItems}
+	 * collection when there is not enough space in the toolbar to display all items in a single row.
+	 *
+	 * @private
+	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
+	 */
+	_createGroupedItemsDropdown() {
+		const locale = this.viewLocale;
+		const t = locale.t;
+		const dropdown = createDropdown( locale );
+
+		dropdown.class = 'ck-toolbar__grouped-dropdown';
+		addToolbarToDropdown( dropdown, [] );
+
+		dropdown.buttonView.set( {
+			label: t( 'Show more items' ),
+			tooltip: true,
+			icon: verticalDotsIcon
+		} );
+
+		// 1:1 pass–through binding.
+		dropdown.toolbarView.items.bindTo( this.groupedItems ).using( item => item );
+
+		return dropdown;
+	}
+
+	/**
+	 * A method that updates the {@link module:ui/toolbar/toolbarview~ToolbarView#focusables focus–cycleable items}
+	 * collection so it represents the up–to–date state of the UI from the perspective of the user.
+	 *
+	 * For instance, the {@link #groupedItemsDropdown} can show up and hide but when it is visible,
+	 * it must be subject to focus cycling in the toolbar.
+	 *
+	 * See the {@link module:ui/toolbar/toolbarview~ToolbarView#focusables collection} documentation
+	 * to learn more about the purpose of this method.
+	 *
+	 * @private
+	 */
+	_updateFocusCycleableItems() {
+		this.viewFocusables.clear();
+
+		this.ungroupedItems.map( item => {
+			this.viewFocusables.add( item );
+		} );
+
+		if ( this.groupedItems.length ) {
+			this.viewFocusables.add( this.groupedItemsDropdown );
+		}
+	}
+}
+
+/**
+ * Options passed to the {@link module:ui/toolbar/toolbarview~ToolbarView#constructor} of the toolbar.
+ *
+ * @interface module:ui/toolbar/toolbarview~ToolbarOptions
+ */
+
+/**
+ * When set `true`, the toolbar will automatically group {@link module:ui/toolbar/toolbarview~ToolbarView#items} that
+ * would normally wrap to the next line when there is not enough space to display them in a single row, for
+ * instance, if the parent container of the toolbar is narrow.
+ *
+ * @member {Boolean} module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull
+ */
+
+/**
+ * A class interface defining a behavior of the {@link module:ui/toolbar/toolbarview~ToolbarView}.
+ *
+ * Toolbar behaviors extend its look and functionality and have an impact on the
+ * {@link module:ui/toolbar/toolbarview~ToolbarView#element} template or
+ * {@link module:ui/toolbar/toolbarview~ToolbarView#render rendering}. They can be enabled
+ * conditionally, e.g. depending on the configuration of the toolbar.
+ *
+ * @private
+ * @interface module:ui/toolbar/toolbarview~ToolbarBehavior
+ */
+
+/**
+ * Creates a new toolbar behavior instance.
+ *
+ * The instance is created in the {@link module:ui/toolbar/toolbarview~ToolbarView#constructor} of the toolbar.
+ * This is the right place to extend the {@link module:ui/toolbar/toolbarview~ToolbarView#template} of
+ * the toolbar, define extra toolbar properties, etc..
+ *
+ * @method #constructor
+ * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior is added to.
+ */
+
+/**
+ * A method called after the toolbar has been {@link module:ui/toolbar/toolbarview~ToolbarView#render rendered}.
+ * E.g. it can be used to customize the behavior of the toolbar when its {@link module:ui/toolbar/toolbarview~ToolbarView#element}
+ * is available.
+ *
+ * @readonly
+ * @member {Function} #render
+ * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar being rendered.
+ */
+
+/**
+ * A method called after the toolbar has been {@link module:ui/toolbar/toolbarview~ToolbarView#destroy destroyed}.
+ * It allows cleaning up after the toolbar behavior, for instance, this is the right place to detach
+ * event listeners, free up references, etc..
+ *
+ * @readonly
+ * @member {Function} #destroy
+ */

+ 11 - 0
packages/ckeditor5-ui/tests/manual/toolbar/grouping.html

@@ -0,0 +1,11 @@
+<h2>Editor with LTR UI</h2>
+
+<div id="editor-ltr">
+	<p>Editor content</p>
+</div>
+
+<h2>Editor with RTL UI</h2>
+
+<div id="editor-rtl">
+	<p>Editor content</p>
+</div>

+ 52 - 0
packages/ckeditor5-ui/tests/manual/toolbar/grouping.js

@@ -0,0 +1,52 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+
+createEditor( '#editor-ltr', 'en' );
+createEditor( '#editor-rtl', 'ar' );
+
+function createEditor( selector, language ) {
+	ClassicEditor
+		.create( document.querySelector( selector ), {
+			plugins: [ ArticlePluginSet ],
+			toolbar: [
+				'heading',
+				'|',
+				'bold',
+				'italic',
+				'link',
+				'|',
+				'bulletedList',
+				'numberedList',
+				'blockQuote',
+				'insertTable',
+				'mediaEmbed',
+				'|',
+				'undo',
+				'redo'
+			],
+			image: {
+				toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
+			},
+			table: {
+				contentToolbar: [
+					'tableColumn',
+					'tableRow',
+					'mergeTableCells'
+				]
+			},
+			language
+		} )
+		.then( editor => {
+			window.editor = editor;
+		} )
+		.catch( err => {
+			console.error( err.stack );
+		} );
+}

+ 36 - 0
packages/ckeditor5-ui/tests/manual/toolbar/grouping.md

@@ -0,0 +1,36 @@
+# Automatic toolbar grouping
+
+## Grouping on load
+
+1. Narrow the browser window so some toolbar items should wrap to the next row.
+2. Refresh the test.
+3. The toolbar should looks the same. Make sure none of toolbar items wrapped or overflow.
+4. The dropdown button should be displayed at the end of the toolbar, allowing to access grouped features.
+5. Grouped items toolbar should never start or end with a separator, even if one was in the main toolbar space.
+6. Other separators (between items) should be preserved.
+
+## Grouping and ungrouping on resize
+
+1. Play with the size of the browser window.
+2. Toolbar items should group and ungroup automatically but
+	* the should never wrap to the next line,
+	* or stick out beyond the toolbar boundaries.
+
+## Accessibility
+
+1. Make sure no toolbar items are grouped.
+2. Use <kbd>Alt</kbd> + <kbd>F10</kbd> (+ <kbd>Fn</kbd> on Mac) to focus the toolbar.
+3. Navigate the toolbar using the keyboard
+	* it should work naturally,
+	* the navigation should cycle (leaving the last item focuses the first item, and going back from the first items focuses the last)
+4. Resize the window so some items are grouped.
+5. Check if navigation works in the same way but includes the button that aggregates grouped items.
+6. Enter the group button, navigate across grouped items, go back (<kbd>Esc</kbd>).
+7. There should be no interruptions or glitches in the navigation.
+
+## RTL UI support
+
+1. Perform the same scenarios in the editor with RTL (right–to–left) UI.
+2. There should be no visual or behavioral difference between LTR and RTL editors except that the toolbar is mirrored.
+3. The button aggregating grouped toolbar items should be displayed on the left–hand side.
+

+ 589 - 50
packages/ckeditor5-ui/tests/toolbar/toolbarview.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
  */
 
 
-/* global document, Event, console */
+/* global document, Event, console, setTimeout */
 
 
 import ToolbarView from '../../src/toolbar/toolbarview';
 import ToolbarView from '../../src/toolbar/toolbarview';
 import ToolbarSeparatorView from '../../src/toolbar/toolbarseparatorview';
 import ToolbarSeparatorView from '../../src/toolbar/toolbarseparatorview';
@@ -13,6 +13,7 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
 import FocusCycler from '../../src/focuscycler';
 import FocusCycler from '../../src/focuscycler';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import ViewCollection from '../../src/viewcollection';
 import ViewCollection from '../../src/viewcollection';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import View from '../../src/view';
 import View from '../../src/view';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { add as addTranslations, _clear as clearTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service';
 import { add as addTranslations, _clear as clearTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service';
@@ -52,11 +53,25 @@ describe( 'ToolbarView', () => {
 			expect( view.locale ).to.equal( locale );
 			expect( view.locale ).to.equal( locale );
 		} );
 		} );
 
 
-		it( 'should set view#isVertical', () => {
-			expect( view.isVertical ).to.be.false;
+		describe( '#options', () => {
+			it( 'should be an empty object if none were passed', () => {
+				expect( view.options ).to.deep.equal( {} );
+			} );
+
+			it( 'should be an empty object if none were passed', () => {
+				const options = {
+					foo: 'bar'
+				};
+
+				const toolbar = new ToolbarView( locale, options );
+
+				expect( toolbar.options ).to.equal( options );
+
+				toolbar.destroy();
+			} );
 		} );
 		} );
 
 
-		it( 'should create view#children collection', () => {
+		it( 'should create view#items collection', () => {
 			expect( view.items ).to.be.instanceOf( ViewCollection );
 			expect( view.items ).to.be.instanceOf( ViewCollection );
 		} );
 		} );
 
 
@@ -68,9 +83,21 @@ describe( 'ToolbarView', () => {
 			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
 			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
 		} );
 		} );
 
 
+		it( 'should create view#itemsView', () => {
+			expect( view.itemsView ).to.be.instanceOf( View );
+		} );
+
+		it( 'should create view#children collection', () => {
+			expect( view.children ).to.be.instanceOf( ViewCollection );
+		} );
+
 		it( 'creates #_focusCycler instance', () => {
 		it( 'creates #_focusCycler instance', () => {
 			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
 			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
 		} );
 		} );
+
+		it( 'creates #_behavior', () => {
+			expect( view._behavior ).to.be.an( 'object' );
+		} );
 	} );
 	} );
 
 
 	describe( 'template', () => {
 	describe( 'template', () => {
@@ -79,6 +106,12 @@ describe( 'ToolbarView', () => {
 			expect( view.element.classList.contains( 'ck-toolbar' ) ).to.true;
 			expect( view.element.classList.contains( 'ck-toolbar' ) ).to.true;
 		} );
 		} );
 
 
+		it( 'should create #itemsView from template', () => {
+			expect( view.element.firstChild ).to.equal( view.itemsView.element );
+			expect( view.itemsView.element.classList.contains( 'ck' ) ).to.true;
+			expect( view.itemsView.element.classList.contains( 'ck-toolbar__items' ) ).to.true;
+		} );
+
 		describe( 'attributes', () => {
 		describe( 'attributes', () => {
 			it( 'should be defined', () => {
 			it( 'should be defined', () => {
 				expect( view.element.getAttribute( 'role' ) ).to.equal( 'toolbar' );
 				expect( view.element.getAttribute( 'role' ) ).to.equal( 'toolbar' );
@@ -93,6 +126,8 @@ describe( 'ToolbarView', () => {
 				view.render();
 				view.render();
 
 
 				expect( view.element.getAttribute( 'aria-label' ) ).to.equal( 'Custom label' );
 				expect( view.element.getAttribute( 'aria-label' ) ).to.equal( 'Custom label' );
+
+				view.destroy();
 			} );
 			} );
 
 
 			it( 'should allow the aria-label to be translated', () => {
 			it( 'should allow the aria-label to be translated', () => {
@@ -101,6 +136,8 @@ describe( 'ToolbarView', () => {
 				view.render();
 				view.render();
 
 
 				expect( view.element.getAttribute( 'aria-label' ) ).to.equal( 'Pasek narzędzi edytora' );
 				expect( view.element.getAttribute( 'aria-label' ) ).to.equal( 'Pasek narzędzi edytora' );
+
+				view.destroy();
 			} );
 			} );
 		} );
 		} );
 
 
@@ -117,14 +154,6 @@ describe( 'ToolbarView', () => {
 
 
 	describe( 'element bindings', () => {
 	describe( 'element bindings', () => {
 		describe( 'class', () => {
 		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#class', () => {
 			it( 'reacts on view#class', () => {
 				view.class = 'foo';
 				view.class = 'foo';
 				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
 				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
@@ -150,6 +179,7 @@ describe( 'ToolbarView', () => {
 			sinon.assert.notCalled( spyAdd );
 			sinon.assert.notCalled( spyAdd );
 
 
 			view.render();
 			view.render();
+
 			sinon.assert.calledTwice( spyAdd );
 			sinon.assert.calledTwice( spyAdd );
 
 
 			view.items.remove( 1 );
 			view.items.remove( 1 );
@@ -171,11 +201,7 @@ describe( 'ToolbarView', () => {
 
 
 		describe( 'activates keyboard navigation for the toolbar', () => {
 		describe( 'activates keyboard navigation for the toolbar', () => {
 			it( 'so "arrowup" focuses previous focusable item', () => {
 			it( 'so "arrowup" focuses previous focusable item', () => {
-				const keyEvtData = {
-					keyCode: keyCodes.arrowup,
-					preventDefault: sinon.spy(),
-					stopPropagation: sinon.spy()
-				};
+				const keyEvtData = getArrowKeyData( 'arrowup' );
 
 
 				// No children to focus.
 				// No children to focus.
 				view.keystrokes.press( keyEvtData );
 				view.keystrokes.press( keyEvtData );
@@ -198,20 +224,15 @@ describe( 'ToolbarView', () => {
 				view.focusTracker.isFocused = true;
 				view.focusTracker.isFocused = true;
 				view.focusTracker.focusedElement = view.items.get( 4 ).element;
 				view.focusTracker.focusedElement = view.items.get( 4 ).element;
 
 
-				const spy = sinon.spy( view.items.get( 2 ), 'focus' );
 				view.keystrokes.press( keyEvtData );
 				view.keystrokes.press( keyEvtData );
 
 
 				sinon.assert.calledThrice( keyEvtData.preventDefault );
 				sinon.assert.calledThrice( keyEvtData.preventDefault );
 				sinon.assert.calledThrice( keyEvtData.stopPropagation );
 				sinon.assert.calledThrice( keyEvtData.stopPropagation );
-				sinon.assert.calledOnce( spy );
+				sinon.assert.calledOnce( view.items.get( 2 ).focus );
 			} );
 			} );
 
 
 			it( 'so "arrowleft" focuses previous focusable item', () => {
 			it( 'so "arrowleft" focuses previous focusable item', () => {
-				const keyEvtData = {
-					keyCode: keyCodes.arrowleft,
-					preventDefault: sinon.spy(),
-					stopPropagation: sinon.spy()
-				};
+				const keyEvtData = getArrowKeyData( 'arrowleft' );
 
 
 				view.items.add( focusable() );
 				view.items.add( focusable() );
 				view.items.add( nonFocusable() );
 				view.items.add( nonFocusable() );
@@ -221,18 +242,12 @@ describe( 'ToolbarView', () => {
 				view.focusTracker.isFocused = true;
 				view.focusTracker.isFocused = true;
 				view.focusTracker.focusedElement = view.items.get( 2 ).element;
 				view.focusTracker.focusedElement = view.items.get( 2 ).element;
 
 
-				const spy = sinon.spy( view.items.get( 0 ), 'focus' );
-
 				view.keystrokes.press( keyEvtData );
 				view.keystrokes.press( keyEvtData );
-				sinon.assert.calledOnce( spy );
+				sinon.assert.calledOnce( view.items.get( 0 ).focus );
 			} );
 			} );
 
 
 			it( 'so "arrowdown" focuses next focusable item', () => {
 			it( 'so "arrowdown" focuses next focusable item', () => {
-				const keyEvtData = {
-					keyCode: keyCodes.arrowdown,
-					preventDefault: sinon.spy(),
-					stopPropagation: sinon.spy()
-				};
+				const keyEvtData = getArrowKeyData( 'arrowdown' );
 
 
 				// No children to focus.
 				// No children to focus.
 				view.keystrokes.press( keyEvtData );
 				view.keystrokes.press( keyEvtData );
@@ -255,20 +270,15 @@ describe( 'ToolbarView', () => {
 				view.focusTracker.isFocused = true;
 				view.focusTracker.isFocused = true;
 				view.focusTracker.focusedElement = view.items.get( 4 ).element;
 				view.focusTracker.focusedElement = view.items.get( 4 ).element;
 
 
-				const spy = sinon.spy( view.items.get( 2 ), 'focus' );
 				view.keystrokes.press( keyEvtData );
 				view.keystrokes.press( keyEvtData );
 
 
 				sinon.assert.calledThrice( keyEvtData.preventDefault );
 				sinon.assert.calledThrice( keyEvtData.preventDefault );
 				sinon.assert.calledThrice( keyEvtData.stopPropagation );
 				sinon.assert.calledThrice( keyEvtData.stopPropagation );
-				sinon.assert.calledOnce( spy );
+				sinon.assert.calledOnce( view.items.get( 2 ).focus );
 			} );
 			} );
 
 
 			it( 'so "arrowright" focuses next focusable item', () => {
 			it( 'so "arrowright" focuses next focusable item', () => {
-				const keyEvtData = {
-					keyCode: keyCodes.arrowright,
-					preventDefault: sinon.spy(),
-					stopPropagation: sinon.spy()
-				};
+				const keyEvtData = getArrowKeyData( 'arrowright' );
 
 
 				view.items.add( focusable() );
 				view.items.add( focusable() );
 				view.items.add( nonFocusable() );
 				view.items.add( nonFocusable() );
@@ -278,16 +288,42 @@ describe( 'ToolbarView', () => {
 				view.focusTracker.isFocused = true;
 				view.focusTracker.isFocused = true;
 				view.focusTracker.focusedElement = view.items.get( 0 ).element;
 				view.focusTracker.focusedElement = view.items.get( 0 ).element;
 
 
-				const spy = sinon.spy( view.items.get( 2 ), 'focus' );
-
 				view.keystrokes.press( keyEvtData );
 				view.keystrokes.press( keyEvtData );
-				sinon.assert.calledOnce( spy );
+				sinon.assert.calledOnce( view.items.get( 2 ).focus );
 			} );
 			} );
 		} );
 		} );
+
+		it( 'calls _behavior#render()', () => {
+			const view = new ToolbarView( locale );
+			sinon.spy( view._behavior, 'render' );
+
+			view.render();
+			sinon.assert.calledOnce( view._behavior.render );
+			sinon.assert.calledWithExactly( view._behavior.render, view );
+
+			view.destroy();
+		} );
+	} );
+
+	describe( 'destroy()', () => {
+		it( 'destroys the feature', () => {
+			sinon.spy( view._behavior, 'destroy' );
+
+			view.destroy();
+
+			sinon.assert.calledOnce( view._behavior.destroy );
+		} );
+
+		it( 'calls _behavior#destroy()', () => {
+			sinon.spy( view._behavior, 'destroy' );
+
+			view.destroy();
+			sinon.assert.calledOnce( view._behavior.destroy );
+		} );
 	} );
 	} );
 
 
 	describe( 'focus()', () => {
 	describe( 'focus()', () => {
-		it( 'focuses the first focusable item in DOM', () => {
+		it( 'focuses the first focusable of #items in DOM', () => {
 			// No children to focus.
 			// No children to focus.
 			view.focus();
 			view.focus();
 
 
@@ -296,15 +332,14 @@ describe( 'ToolbarView', () => {
 			view.items.add( focusable() );
 			view.items.add( focusable() );
 			view.items.add( nonFocusable() );
 			view.items.add( nonFocusable() );
 
 
-			const spy = sinon.spy( view.items.get( 1 ), 'focus' );
 			view.focus();
 			view.focus();
 
 
-			sinon.assert.calledOnce( spy );
+			sinon.assert.calledOnce( view.items.get( 1 ).focus );
 		} );
 		} );
 	} );
 	} );
 
 
 	describe( 'focusLast()', () => {
 	describe( 'focusLast()', () => {
-		it( 'focuses the last focusable item in DOM', () => {
+		it( 'focuses the last focusable of #items in DOM', () => {
 			// No children to focus.
 			// No children to focus.
 			view.focusLast();
 			view.focusLast();
 
 
@@ -315,10 +350,9 @@ describe( 'ToolbarView', () => {
 			view.items.add( focusable() );
 			view.items.add( focusable() );
 			view.items.add( nonFocusable() );
 			view.items.add( nonFocusable() );
 
 
-			const spy = sinon.spy( view.items.get( 3 ), 'focus' );
 			view.focusLast();
 			view.focusLast();
 
 
-			sinon.assert.calledOnce( spy );
+			sinon.assert.calledOnce( view.items.get( 3 ).focus );
 		} );
 		} );
 	} );
 	} );
 
 
@@ -361,19 +395,516 @@ describe( 'ToolbarView', () => {
 			);
 			);
 		} );
 		} );
 	} );
 	} );
+
+	describe( 'toolbar with static items', () => {
+		describe( 'constructor()', () => {
+			it( 'should set view#isVertical', () => {
+				expect( view.isVertical ).to.be.false;
+			} );
+
+			it( 'binds itemsView#children to #items', () => {
+				const itemA = focusable();
+				const itemB = focusable();
+				const itemC = focusable();
+
+				view.items.add( itemA );
+				view.items.add( itemB );
+				view.items.add( itemC );
+
+				expect( view.itemsView.children.map( i => i ) ).to.have.ordered.members( [ itemA, itemB, itemC ] );
+			} );
+
+			it( 'binds #focusables to #items', () => {
+				const itemA = focusable();
+				const itemB = focusable();
+				const itemC = focusable();
+
+				view.items.add( itemA );
+				view.items.add( itemB );
+				view.items.add( itemC );
+
+				expect( view.focusables.map( i => i ) ).to.have.ordered.members( [ itemA, itemB, itemC ] );
+			} );
+		} );
+
+		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;
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'toolbar with a dynamic item grouping', () => {
+		let locale, view, groupedItems, ungroupedItems, groupedItemsDropdown;
+
+		beforeEach( () => {
+			locale = new Locale();
+			view = new ToolbarView( locale, {
+				shouldGroupWhenFull: true
+			} );
+			view.render();
+			view.element.style.width = '200px';
+			document.body.appendChild( view.element );
+
+			groupedItems = view._behavior.groupedItems;
+			ungroupedItems = view._behavior.ungroupedItems;
+			groupedItemsDropdown = view._behavior.groupedItemsDropdown;
+		} );
+
+		afterEach( () => {
+			sinon.restore();
+			view.element.remove();
+			view.destroy();
+		} );
+
+		describe( 'constructor()', () => {
+			it( 'extends the template with the CSS class', () => {
+				expect( view.element.classList.contains( 'ck-toolbar_grouping' ) ).to.be.true;
+			} );
+
+			it( 'updates the UI as new #items are added', () => {
+				sinon.spy( view._behavior, '_updateGrouping' );
+
+				const itemA = focusable();
+				const itemB = focusable();
+				const itemC = focusable();
+				const itemD = focusable();
+
+				view.element.style.width = '200px';
+
+				view.items.add( itemA );
+				view.items.add( itemB );
+
+				sinon.assert.calledTwice( view._behavior._updateGrouping );
+
+				expect( ungroupedItems ).to.have.length( 2 );
+				expect( groupedItems ).to.have.length( 0 );
+
+				view.items.add( itemC );
+
+				// The dropdown took some extra space.
+				expect( ungroupedItems ).to.have.length( 1 );
+				expect( groupedItems ).to.have.length( 2 );
+
+				view.items.add( itemD, 2 );
+
+				expect( ungroupedItems ).to.have.length( 1 );
+				expect( groupedItems ).to.have.length( 3 );
+
+				expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA ] );
+				expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemD, itemC ] );
+			} );
+
+			it( 'updates the UI as #items are removed', () => {
+				const itemA = focusable();
+				const itemB = focusable();
+				const itemC = focusable();
+				const itemD = focusable();
+
+				view.element.style.width = '200px';
+
+				view.items.add( itemA );
+				view.items.add( itemB );
+				view.items.add( itemC );
+				view.items.add( itemD );
+
+				sinon.spy( view._behavior, '_updateGrouping' );
+				view.items.remove( 2 );
+
+				expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA ] );
+				expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemD ] );
+
+				sinon.assert.calledOnce( view._behavior._updateGrouping );
+
+				view.items.remove( 0 );
+				sinon.assert.calledTwice( view._behavior._updateGrouping );
+
+				expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemD ] );
+			} );
+		} );
+
+		it( 'groups items that overflow into the dropdown', () => {
+			const itemA = focusable();
+			const itemB = focusable();
+			const itemC = focusable();
+			const itemD = focusable();
+
+			view.items.add( itemA );
+			view.items.add( itemB );
+			view.items.add( itemC );
+			view.items.add( itemD );
+
+			expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA ] );
+			expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemC, itemD ] );
+			expect( view.children ).to.have.length( 3 );
+			expect( view.children.get( 0 ) ).to.equal( view.itemsView );
+			expect( view.children.get( 1 ) ).to.be.instanceOf( ToolbarSeparatorView );
+			expect( view.children.get( 2 ) ).to.equal( groupedItemsDropdown );
+		} );
+
+		it( 'ungroups items if there is enough space to display them (all)', () => {
+			const itemA = focusable();
+			const itemB = focusable();
+			const itemC = focusable();
+			const itemD = focusable();
+
+			view.items.add( itemA );
+			view.items.add( itemB );
+			view.items.add( itemC );
+			view.items.add( itemD );
+
+			expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA ] );
+			expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemC, itemD ] );
+
+			view.element.style.width = '350px';
+
+			// Some grouped items cannot be ungrouped because there is not enough space and they will
+			// land back in #_behavior.groupedItems after an attempt was made.
+			view._behavior._updateGrouping();
+			expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA, itemB, itemC ] );
+			expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemD ] );
+		} );
+
+		it( 'ungroups items if there is enough space to display them (some)', () => {
+			const itemA = focusable();
+			const itemB = focusable();
+			const itemC = focusable();
+
+			view.items.add( itemA );
+			view.items.add( itemB );
+			view.items.add( itemC );
+
+			expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA ] );
+			expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemC ] );
+
+			view.element.style.width = '350px';
+
+			// All grouped items will be ungrouped because they fit just alright in the main space.
+			view._behavior._updateGrouping();
+			expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA, itemB, itemC ] );
+			expect( groupedItems ).to.have.length( 0 );
+			expect( view.children ).to.have.length( 1 );
+			expect( view.children.get( 0 ) ).to.equal( view.itemsView );
+		} );
+
+		describe( 'render()', () => {
+			it( 'starts observing toolbar resize immediatelly after render', () => {
+				function FakeResizeObserver( callback ) {
+					this.callback = callback;
+				}
+
+				FakeResizeObserver.prototype.observe = sinon.spy();
+				FakeResizeObserver.prototype.disconnect = sinon.spy();
+
+				testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( FakeResizeObserver );
+
+				const view = new ToolbarView( locale, {
+					shouldGroupWhenFull: true
+				} );
+
+				view.render();
+
+				sinon.assert.calledOnce( view._behavior.resizeObserver.observe );
+				sinon.assert.calledWithExactly( view._behavior.resizeObserver.observe, view.element );
+
+				view.destroy();
+			} );
+
+			it( 'updates the UI when the toolbar is being resized (expanding)', done => {
+				view.element.style.width = '200px';
+
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+
+				expect( ungroupedItems ).to.have.length( 1 );
+				expect( groupedItems ).to.have.length( 4 );
+
+				view.element.style.width = '500px';
+
+				setTimeout( () => {
+					expect( ungroupedItems ).to.have.length( 5 );
+					expect( groupedItems ).to.have.length( 0 );
+
+					done();
+				}, 100 );
+			} );
+
+			it( 'updates the UI when the toolbar is being resized (narrowing)', done => {
+				view.element.style.width = '500px';
+
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+
+				expect( ungroupedItems ).to.have.length( 5 );
+				expect( groupedItems ).to.have.length( 0 );
+
+				view.element.style.width = '200px';
+
+				setTimeout( () => {
+					expect( ungroupedItems ).to.have.length( 1 );
+					expect( groupedItems ).to.have.length( 4 );
+
+					done();
+				}, 100 );
+			} );
+
+			it( 'does not react to changes in height', done => {
+				view.element.style.width = '500px';
+				view.element.style.height = '200px';
+
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+
+				sinon.spy( view._behavior, '_updateGrouping' );
+				view.element.style.width = '500px';
+
+				setTimeout( () => {
+					sinon.assert.calledOnce( view._behavior._updateGrouping );
+					view.element.style.height = '500px';
+
+					setTimeout( () => {
+						sinon.assert.calledOnce( view._behavior._updateGrouping );
+						done();
+					}, 100 );
+				}, 100 );
+			} );
+
+			it( 'updates the state of grouped items upon resize', () => {
+				function FakeResizeObserver( callback ) {
+					this.callback = callback;
+				}
+
+				FakeResizeObserver.prototype.observe = sinon.spy();
+				FakeResizeObserver.prototype.disconnect = sinon.spy();
+
+				testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( FakeResizeObserver );
+
+				const view = new ToolbarView( locale, {
+					shouldGroupWhenFull: true
+				} );
+
+				testUtils.sinon.spy( view._behavior, '_updateGrouping' );
+
+				view.render();
+
+				view._behavior.resizeObserver.callback( [
+					{ contentRect: { width: 42 } }
+				] );
+
+				sinon.assert.calledTwice( view._behavior._updateGrouping );
+
+				view.destroy();
+			} );
+		} );
+
+		describe( 'destroy()', () => {
+			it( 'destroys the #groupedItemsDropdown', () => {
+				view.element.style.width = '200px';
+
+				const itemA = focusable();
+				const itemB = focusable();
+				const itemC = focusable();
+				const itemD = focusable();
+
+				view.items.add( itemA );
+				view.items.add( itemB );
+				view.items.add( itemC );
+				view.items.add( itemD );
+
+				sinon.spy( groupedItemsDropdown, 'destroy' );
+
+				view.element.style.width = '500px';
+
+				// The dropdown hides; it does not belong to any collection but it still exist.
+				view._behavior._updateGrouping();
+
+				view.destroy();
+				sinon.assert.calledOnce( groupedItemsDropdown.destroy );
+			} );
+
+			it( 'disconnects the #resizeObserver', () => {
+				view.element.style.width = '200px';
+
+				const itemA = focusable();
+				const itemB = focusable();
+				const itemC = focusable();
+				const itemD = focusable();
+
+				view.items.add( itemA );
+				view.items.add( itemB );
+				view.items.add( itemC );
+				view.items.add( itemD );
+
+				sinon.spy( view._behavior.resizeObserver, 'disconnect' );
+
+				view.destroy();
+				sinon.assert.calledOnce( view._behavior.resizeObserver.disconnect );
+			} );
+		} );
+
+		describe( 'dropdown with grouped items', () => {
+			it( 'has proper DOM structure', () => {
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+
+				expect( view.children.has( groupedItemsDropdown ) ).to.be.true;
+				expect( groupedItemsDropdown.element.classList.contains( 'ck-toolbar__grouped-dropdown' ) );
+				expect( groupedItemsDropdown.buttonView.label ).to.equal( 'Show more items' );
+			} );
+
+			it( 'shares its toolbarView#items with grouped items', () => {
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+
+				expect( groupedItemsDropdown.toolbarView.items.map( i => i ) )
+					.to.have.ordered.members( groupedItems.map( i => i ) );
+			} );
+		} );
+
+		describe( 'item overflow checking logic', () => {
+			it( 'considers the right padding of the toolbar (LTR UI)', () => {
+				view.class = 'ck-reset_all';
+				view.element.style.width = '210px';
+				view.element.style.paddingLeft = '0px';
+				view.element.style.paddingRight = '20px';
+
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+
+				expect( view._behavior.groupedItems ).to.have.length( 1 );
+			} );
+
+			it( 'considers the left padding of the toolbar (RTL UI)', () => {
+				const locale = new Locale( { uiLanguage: 'ar' } );
+				const view = new ToolbarView( locale, {
+					shouldGroupWhenFull: true
+				} );
+
+				view.extendTemplate( {
+					attributes: {
+						dir: locale.uiLanguageDirection
+					}
+				} );
+
+				view.render();
+				document.body.appendChild( view.element );
+
+				view.class = 'ck-reset_all';
+				view.element.style.width = '210px';
+				view.element.style.paddingLeft = '20px';
+				view.element.style.paddingRight = '0px';
+
+				view.items.add( focusable() );
+				view.items.add( focusable() );
+
+				expect( view._behavior.groupedItems ).to.have.length( 1 );
+
+				view.destroy();
+				view.element.remove();
+			} );
+		} );
+
+		describe( 'focus management', () => {
+			it( '#focus() focuses the dropdown when it is the only focusable', () => {
+				sinon.spy( groupedItemsDropdown, 'focus' );
+				view.element.style.width = '10px';
+
+				const itemA = focusable();
+				const itemB = focusable();
+
+				view.items.add( itemA );
+				view.items.add( itemB );
+
+				expect( view.focusables.map( i => i ) ).to.have.ordered.members( [ groupedItemsDropdown ] );
+
+				view.focus();
+				sinon.assert.calledOnce( groupedItemsDropdown.focus );
+			} );
+
+			it( '#focusLast() focuses the dropdown when present', () => {
+				sinon.spy( groupedItemsDropdown, 'focus' );
+				view.element.style.width = '200px';
+
+				const itemA = focusable();
+				const itemB = focusable();
+				const itemC = focusable();
+
+				view.items.add( itemA );
+				view.items.add( itemB );
+				view.items.add( itemC );
+
+				expect( view.focusables.map( i => i ) ).to.have.ordered.members( [ itemA, groupedItemsDropdown ] );
+
+				view.focusLast();
+
+				sinon.assert.calledOnce( groupedItemsDropdown.focus );
+
+				view.element.remove();
+			} );
+		} );
+	} );
 } );
 } );
 
 
 function focusable() {
 function focusable() {
 	const view = nonFocusable();
 	const view = nonFocusable();
 
 
-	view.focus = () => {};
+	view.label = 'focusable';
+	view.focus = sinon.stub().callsFake( () => {
+		view.element.focus();
+	} );
+
+	view.extendTemplate( {
+		attributes: {
+			tabindex: -1
+		}
+	} );
 
 
 	return view;
 	return view;
 }
 }
 
 
 function nonFocusable() {
 function nonFocusable() {
 	const view = new View();
 	const view = new View();
-	view.element = document.createElement( 'li' );
+
+	view.set( 'label', 'non-focusable' );
+
+	const bind = view.bindTemplate;
+
+	view.setTemplate( {
+		tag: 'div',
+		attributes: {
+			style: {
+				padding: '0',
+				margin: '0',
+				width: '100px',
+				height: '100px',
+				background: 'rgba(255,0,0,.3)'
+			}
+		},
+		children: [
+			{
+				text: bind.to( 'label' )
+			}
+		]
+	} );
 
 
 	return view;
 	return view;
 }
 }
@@ -388,3 +919,11 @@ function namedFactory( name ) {
 		return view;
 		return view;
 	};
 	};
 }
 }
+
+function getArrowKeyData( arrow ) {
+	return {
+		keyCode: keyCodes[ arrow ],
+		preventDefault: sinon.spy(),
+		stopPropagation: sinon.spy()
+	};
+}

+ 1 - 1
packages/ckeditor5-ui/theme/components/dropdown/toolbardropdown.css

@@ -4,7 +4,7 @@
  */
  */
 
 
 .ck.ck-toolbar-dropdown {
 .ck.ck-toolbar-dropdown {
-	& .ck-toolbar {
+	& .ck.ck-toolbar .ck.ck-toolbar__items {
 		flex-wrap: nowrap;
 		flex-wrap: nowrap;
 	}
 	}
 
 

+ 34 - 11
packages/ckeditor5-ui/theme/components/toolbar/toolbar.css

@@ -9,23 +9,46 @@
 	@mixin ck-unselectable;
 	@mixin ck-unselectable;
 
 
 	display: flex;
 	display: flex;
-	flex-flow: row wrap;
+	flex-flow: row nowrap;
 	align-items: center;
 	align-items: center;
 
 
-	&.ck-toolbar_vertical {
-		flex-direction: column;
+	& > .ck-toolbar__items {
+		display: flex;
+		flex-flow: row wrap;
+		align-items: center;
+		flex-grow: 1;
+
 	}
 	}
 
 
-	&.ck-toolbar_floating {
+	& .ck.ck-toolbar__separator {
+		display: inline-block;
+
+		/*
+		 * A leading or trailing separator makes no sense (separates from nothing on one side).
+		 * For instance, it can happen when toolbar items (also separators) are getting grouped and one by and
+		 * moved to another toolbar in the drop–down.
+		 */
+		&:first-child,
+		&:last-child {
+			display: none;
+		}
+	}
+
+	&.ck-toolbar_grouping > .ck-toolbar__items {
 		flex-wrap: nowrap;
 		flex-wrap: nowrap;
 	}
 	}
-}
 
 
-.ck.ck-toolbar__separator {
-	display: inline-block;
-}
+	&.ck-toolbar_vertical > .ck-toolbar__items {
+		flex-direction: column;
+	}
+
+	&.ck-toolbar_floating > .ck-toolbar__items {
+		flex-wrap: nowrap;
+	}
 
 
-.ck.ck-toolbar__newline {
-	display: block;
-	width: 100%;
+	& > .ck.ck-toolbar__grouped-dropdown {
+		& > .ck-dropdown__button .ck-dropdown__arrow {
+			display: none;
+		}
+	}
 }
 }