Procházet zdrojové kódy

Merge branch 't/ckeditor5/416e' into t/ckeditor5/416d

Aleksander Nowodzinski před 6 roky
rodič
revize
af5a9a4910

+ 516 - 454
packages/ckeditor5-ui/src/toolbar/toolbarview.js

@@ -27,15 +27,6 @@ import '../../theme/components/toolbar/toolbar.css';
 /**
  * The toolbar view class.
  *
- *	┌─────────────────────────────────── ToolbarView ────────────────────────────────────────┐
- *	| ┌───────────────────────────────── #_components ─────────────────────────────────────┐ |
- *	| |   ┌──── #itemsView───────┐ ┌──────────────────────┐ ┌──#groupedItemsDropdown───┐   | |
- *	| |   |        #items        | | ToolbarSeparatorView | |      #groupedItems       |   | |
- *	| |   └─────────────────────-┘ └──────────────────────┘ └──────────────────────────┘   | |
- *	| |                            \----- only when #shouldGroupWhenFull = true -------/   | |
- *	| └────────────────────────────────────────────────────────────────────────────────────┘ |
- *	└────────────────────────────────────────────────────────────────────────────────────────┘
- *
  * @extends module:ui/view~View
  * @implements module:ui/dropdown/dropdownpanelfocusable~DropdownPanelFocusable
  */
@@ -46,13 +37,22 @@ export default class ToolbarView extends View {
 	 * Also see {@link #render}.
 	 *
 	 * @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 );
 
 		const bind = this.bindTemplate;
 		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.
 		 *
@@ -64,26 +64,11 @@ export default class ToolbarView extends View {
 		/**
 		 * Collection of the toolbar items (buttons, drop–downs, etc.).
 		 *
-		 * **Note:** When {@link #shouldGroupWhenFull} is `true`, items that do not fit into a single
-		 * row of a toolbar will be moved to the {@link #groupedItems} collection. Check out
-		 * {@link #shouldGroupWhenFull} to learn more.
-		 *
 		 * @readonly
 		 * @member {module:ui/viewcollection~ViewCollection}
 		 */
 		this.items = this.createCollection();
 
-		/**
-		 * Collection of the toolbar items (buttons, drop–downs, etc.) that do not fit into a single
-		 * row of the toolbar, created on demand when {@link #shouldGroupWhenFull} is `true`. The
-		 * toolbar transfers its items between {@link #items} and this collection dynamically as
-		 * the geometry changes.
-		 *
-		 * @readonly
-		 * @member {module:ui/viewcollection~ViewCollection}
-		 */
-		this.groupedItems = null;
-
 		/**
 		 * Tracks information about DOM focus in the toolbar.
 		 *
@@ -101,40 +86,6 @@ export default class ToolbarView extends View {
 		 */
 		this.keystrokes = new KeystrokeHandler();
 
-		/**
-		 * The dropdown that aggregates {@link #items} that do not fit into a single row of the toolbar.
-		 * It is displayed at the end of the toolbar and offers another (nested) toolbar which displays
-		 * items that would normally overflow. Its content corresponds to the {@link #groupedItems}
-		 * collection.
-		 *
-		 * **Note:** Created on demand when there is not enough space in the toolbar and only
-		 * if {@link #shouldGroupWhenFull} is `true`. If the geometry of the toolbar changes allowing
-		 * all items in a single row again, the dropdown will hide.
-		 *
-		 * @readonly
-		 * @member {module:ui/dropdown/dropdownview~DropdownView} #groupedItemsDropdown
-		 */
-		this.groupedItemsDropdown = null;
-
-		/**
-		 * A view containing toolbar {@link #items}.
-		 *
-		 * **Note:** When {@link #shouldGroupWhenFull} is `true`, items that do not fit into a single
-		 * row of a toolbar will be moved to the {@link #groupedItemsDropdown}.
-		 *
-		 * @readonly
-		 * @member {module:ui/toolbar/toolbarview~ToolbarItemsView}
-		 */
-		this.itemsView = this._createItemsView();
-
-		/**
-		 * Controls the orientation of toolbar items.
-		 *
-		 * @observable
-		 * @member {Boolean} #isVertical
-		 */
-		this.set( 'isVertical', false );
-
 		/**
 		 * An additional CSS class added to the {@link #element}.
 		 *
@@ -144,122 +95,72 @@ export default class ToolbarView extends View {
 		this.set( 'class' );
 
 		/**
-		 * When set `true`, the toolbar will automatically group {@link #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 is narrow.
-		 *
-		 * Grouped items land in the {@link #groupedItemsDropdown drop–down} displayed on–demand
-		 * at the end of the toolbar. When the geometry of the toolbar allows all items to be displayed
-		 * in a single row again, they will be moved from the drop–down back to the main space.
-		 *
-		 * @observable
-		 * @member {Boolean} #shouldGroupWhenFull
-		 */
-		this.set( 'shouldGroupWhenFull', false );
-
-		// Grouping can be enabled before or after render.
-		//
-		// **Note**: Possibly in the future a possibility to turn the automatic grouping off could be
-		// required. As for now, there is no such need, so there is no such functionality.
-		//
-		// **Note**: Low priority ensures the grouping logic is executed AFTER the template reacts
-		// to this observable property. Otherwise, the view element will be missing a CSS class
-		// that prevents toolbar items from wrapping to the next line and the overflow detection
-		// logic will not be able to tell if items are overflowing or not.
-		this.on( 'change:shouldGroupWhenFull', () => {
-			if ( this.shouldGroupWhenFull ) {
-				this._enableOverflowedItemsGroupingOnResize();
-			}
-		}, { priority: 'low' } );
-
-		/**
-		 * 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 dynamically only when {@link #shouldGroupWhenFull} is `true`.
+		 * A (child) view containing {@link #items toolbar items}.
 		 *
 		 * @readonly
-		 * @protected
-		 * @member {module:utils/dom/getresizeobserver~ResizeObserver}
+		 * @member {module:ui/toolbar/toolbarview~ItemsView}
 		 */
-		this._groupWhenFullResizeObserver = null;
+		this.itemsView = new ItemsView( locale );
 
 		/**
-		 * A flag used by {@link #updateGroupedItems} method to make sure no concurrent updates
-		 * are performed to the {@link #items} and {@link #groupedItems}. Because {@link #updateGroupedItems}
-		 * manages those collections but also is executed upon changes in those collections, this flag
-		 * ensures no infinite loops occur.
+		 * A top–level collection aggregating building blocks of the toolbar.
 		 *
-		 * **Note:** Used only when {@link #shouldGroupWhenFull} is `true`.
-		 *
-		 * @readonly
-		 * @private
-		 * @member {Boolean}
-		 */
-		this._groupWhenFullLock = false;
-
-		/**
-		 * A cached value of the horizontal padding style used by {@link #updateGroupedItems}
-		 * to manage the {@link #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.
+		 *	┌───────────────── ToolbarView ─────────────────┐
+		 *	| ┌──────────────── #children ────────────────┐ |
+		 *	| |   ┌──────────── #itemsView ───────────┐   | |
+		 *	| |   | [ item1 ] [ item2 ] ... [ itemN ] |   | |
+		 *	| |   └──────────────────────────────────-┘   | |
+		 *	| └───────────────────────────────────────────┘ |
+		 *	└───────────────────────────────────────────────┘
 		 *
-		 * **Note:** Set only when {@link #shouldGroupWhenFull} is `true`.
+		 * By default, it contains the {@link #itemsView} but it can be extended with additional
+		 * UI elements when necessary.
 		 *
 		 * @readonly
-		 * @private
-		 * @member {Number}
+		 * @member {module:ui/viewcollection~ViewCollection}
 		 */
-		this._groupWhenFullCachedPadding = null;
+		this.children = this.createCollection();
+		this.children.add( this.itemsView );
 
 		/**
-		 * A top–level collection aggregating building blocks of the toolbar. It mainly exists to
-		 * make sure {@link #items} do not mix up with the {@link #groupedItemsDropdown}, which helps
-		 * a lot with the {@link #shouldGroupWhenFull} logic (no re–ordering issues, exclusions, etc.).
-		 *
-		 * Please refer to the diagram in the documentation of the class to learn more.
+		 * 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
-		 * @protected
 		 * @member {module:ui/viewcollection~ViewCollection}
 		 */
-		this._components = this.createCollection();
-		this._components.add( this.itemsView );
+		this.focusables = this.createCollection();
 
 		/**
-		 * Helps cycling over focusable {@link #items} in the toolbar residing in the {@link #itemsView}.
+		 * Controls the orientation of toolbar items. Only available when
+		 * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull dynamic items grouping}
+		 * is **disabled**.
 		 *
-		 * The top–level cycling (e.g. between the items and the {@link #groupedItemsDropdown}) is
-		 * handled by the {@link #_componentsFocusCycler}.
-		 *
-		 * @readonly
-		 * @protected
-		 * @member {module:ui/focuscycler~FocusCycler}
+		 * @observable
+		 * @member {Boolean} #isVertical
 		 */
-		this._itemsFocusCycler = new FocusCycler( {
-			focusables: this.itemsView.items,
-			focusTracker: this.itemsView.focusTracker,
-		} );
 
 		/**
-		 * Helps cycling over building blocks ({@link #_components}) of the toolbar, mainly over
-		 * the {@link #itemsView} and the {@link #groupedItemsDropdown}.
-		 *
-		 * The {@link #items}–level cycling is handled by the {@link #_itemsFocusCycler}.
+		 * Helps cycling over {@link #focusables focusable items} in the toolbar.
 		 *
 		 * @readonly
 		 * @protected
 		 * @member {module:ui/focuscycler~FocusCycler}
 		 */
-		this._componentsFocusCycler = new FocusCycler( {
-			focusables: this._components,
+		this._focusCycler = new FocusCycler( {
+			focusables: this.focusables,
 			focusTracker: this.focusTracker,
-		} );
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate toolbar items backwards using the arrow[left,up] keys.
+				focusPrevious: [ 'arrowleft', 'arrowup' ],
 
-		this.keystrokes.set( 'arrowleft', this._focusPrevious.bind( this ) );
-		this.keystrokes.set( 'arrowup', this._focusPrevious.bind( this ) );
-		this.keystrokes.set( 'arrowright', this._focusNext.bind( this ) );
-		this.keystrokes.set( 'arrowdown', this._focusNext.bind( this ) );
+				// Navigate toolbar items forwards using the arrow[right,down] keys.
+				focusNext: [ 'arrowright', 'arrowdown' ]
+			}
+		} );
 
 		this.setTemplate( {
 			tag: 'div',
@@ -267,21 +168,30 @@ export default class ToolbarView extends View {
 				class: [
 					'ck',
 					'ck-toolbar',
-					bind.if( 'isVertical', 'ck-toolbar_vertical' ),
-					bind.if( 'shouldGroupWhenFull', 'ck-toolbar_grouping' ),
 					bind.to( 'class' )
 				],
 				role: 'toolbar',
 				'aria-label': bind.to( 'ariaLabel' )
 			},
 
-			children: this._components,
+			children: this.children,
 
 			on: {
 				// https://github.com/ckeditor/ckeditor5-ui/issues/206
 				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 );
 	}
 
 	/**
@@ -290,70 +200,46 @@ export default class ToolbarView extends View {
 	render() {
 		super.render();
 
-		// Components added before rendering should be known to the #focusTracker.
-		for ( const component of this._components ) {
-			this.focusTracker.add( component.element );
+		// children added before rendering should be known to the #focusTracker.
+		for ( const item of this.items ) {
+			this.focusTracker.add( item.element );
 		}
 
-		this._components.on( 'add', ( evt, component ) => {
-			this.focusTracker.add( component.element );
-		} );
-
-		this._components.on( 'remove', ( evt, component ) => {
-			this.focusTracker.remove( component.element );
-		} );
-
-		this.items.on( 'add', () => {
-			this.updateGroupedItems();
+		this.items.on( 'add', ( evt, item ) => {
+			this.focusTracker.add( item.element );
 		} );
 
-		this.items.on( 'remove', () => {
-			this.updateGroupedItems();
+		this.items.on( 'remove', ( evt, item ) => {
+			this.focusTracker.remove( item.element );
 		} );
 
 		// Start listening for the keystrokes coming from #element.
 		this.keystrokes.listenTo( this.element );
+
+		this._behavior.render( this );
 	}
 
 	/**
 	 * @inheritDoc
 	 */
 	destroy() {
-		// The dropdown may not be in #_components at the moment of toolbar destruction
-		// so let's make sure it's actually destroyed along with the toolbar.
-		if ( this.groupedItemsDropdown ) {
-			this.groupedItemsDropdown.destroy();
-		}
-
-		if ( this._groupWhenFullResizeObserver ) {
-			this._groupWhenFullResizeObserver.disconnect();
-		}
+		this._behavior.destroy();
 
 		return super.destroy();
 	}
 
 	/**
-	 * Focuses the first focusable in element in the toolbar.
+	 * Focuses the first focusable in {@link #focusables}.
 	 */
 	focus() {
-		if ( this._itemsFocusCycler.first ) {
-			this._itemsFocusCycler.focusFirst();
-		} else if ( this.groupedItems && this.groupedItems.length ) {
-			this.groupedItemsDropdown.focus();
-		}
+		this._focusCycler.focusFirst();
 	}
 
 	/**
-	 * Focuses the last focusable element in the toolbar.
+	 * Focuses the last focusable in {@link #focusables}.
 	 */
 	focusLast() {
-		const last = this._componentsFocusCycler.last;
-
-		if ( last === this.itemsView ) {
-			this.itemsView._focusCycler.focusLast();
-		} else {
-			this._componentsFocusCycler.focusLast();
-		}
+		this._focusCycler.focusLast();
 	}
 
 	/**
@@ -367,11 +253,11 @@ export default class ToolbarView extends View {
 		// The toolbar is filled in in the reverse order for the toolbar grouping to work properly.
 		// If we filled it in in the natural order, items that overflow would be grouped
 		// in a revere order.
-		config.reverse().map( name => {
+		config.map( name => {
 			if ( name == '|' ) {
-				this.items.add( new ToolbarSeparatorView(), 0 );
+				this.items.add( new ToolbarSeparatorView() );
 			} else if ( factory.has( name ) ) {
-				this.items.add( factory.create( name ), 0 );
+				this.items.add( factory.create( name ) );
 			} else {
 				/**
 				 * There was a problem processing the configuration of the toolbar. The item with the given
@@ -395,42 +281,319 @@ 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 {
 	/**
-	 * When called, if {@link #shouldGroupWhenFull} is `true`, it will check if any of the {@link #items}
-	 * do not fit into a single row of the toolbar, and it will move them to the {@link #groupedItems}
-	 * when it happens.
+	 * @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.
+ * It also allows toolbar with the vertical layout.
+ *
+ * @private
+ * @implements module:ui/toolbar/toolbarview~ToolbarBehavior
+ */
+class StaticLayout {
+	/**
+	 * Creates an instance of the {@link module:ui/toolbar/toolbarview~StaticLayout} toolbar
+	 * behavior.
 	 *
-	 * 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 #items} and still fit into a single row
-	 * without the toolbar wrapping.
+	 * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior
+	 * is added to.
 	 */
-	updateGroupedItems() {
-		if ( !this.shouldGroupWhenFull ) {
-			return;
-		}
+	constructor( view ) {
+		const bind = view.bindTemplate;
 
-		// Do not check when another check is going on to avoid infinite loops.
-		// This method is called when adding and removing #items but at the same time it adds and removes
-		// #items itself.
-		if ( this._groupWhenFullLock ) {
-			return;
-		}
+		// 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.element.ownerDocument.body.contains( this.element ) ) {
+		if ( !this.viewElement.ownerDocument.body.contains( this.viewElement ) ) {
 			return;
 		}
 
-		// There's no way to make any decisions concerning geometry when there is no element to work with
-		// (before #render()). Or when element has no parent because ClientRects won't work when
-		// #element is not in DOM.
-
-		this._groupWhenFullLock = true;
-
 		let wereItemsGrouped;
 
 		// Group #items as long as some wrap to the next row. This will happen, for instance,
@@ -459,313 +622,212 @@ export default class ToolbarView extends View {
 				this._groupLastItem();
 			}
 		}
-
-		this._groupWhenFullLock = false;
 	}
 
 	/**
-	 * Returns `true` when any of toolbar {@link #items} visually overflows, for instance if the
-	 * toolbar is narrower than its members. `false` otherwise.
+	 * Enables the functionality that prevents {@link #ungroupedItems} from overflowing
+	 * (wrapping to the next row) 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 space, which has its own nested toolbar.
 	 *
-	 * **Note**: Technically speaking, if not for the {@link #shouldGroupWhenFull}, the items would
-	 * wrap and break the toolbar into multiple rows. Overflowing is only possible when
-	 *  {@link #shouldGroupWhenFull} is `true`.
+	 * 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.
 	 *
-	 * @protected
+	 * @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();
+	}
+
+	/**
+	 * 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.items.length ) {
+		if ( !this.ungroupedItems.length ) {
 			return false;
 		}
 
-		const uiLanguageDirection = this.locale.uiLanguageDirection;
-		const lastChildRect = new Rect( this.element.lastChild );
-		const toolbarRect = new Rect( this.element );
+		const element = this.viewElement;
+		const uiLanguageDirection = this.viewLocale.uiLanguageDirection;
+		const lastChildRect = new Rect( element.lastChild );
+		const toolbarRect = new Rect( element );
 
-		if ( !this._groupWhenFullCachedPadding ) {
-			const computedStyle = global.window.getComputedStyle( this.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._groupWhenFullCachedPadding = Number.parseInt( computedStyle[ paddingProperty ] );
+			this.cachedPadding = Number.parseInt( computedStyle[ paddingProperty ] );
 		}
 
 		if ( uiLanguageDirection === 'ltr' ) {
-			return lastChildRect.right > toolbarRect.right - this._groupWhenFullCachedPadding;
+			return lastChildRect.right > toolbarRect.right - this.cachedPadding;
 		} else {
-			return lastChildRect.left < toolbarRect.left + this._groupWhenFullCachedPadding;
+			return lastChildRect.left < toolbarRect.left + this.cachedPadding;
 		}
 	}
 
 	/**
-	 * Creates the {@link #itemsView} that hosts the members of the {@link #items} collection.
-	 *
-	 * @protected
-	 * @returns {module:ui/view~View}
-	 */
-	_createItemsView() {
-		const itemsView = new ToolbarItemsView( this.locale );
-
-		// 1:1 pass–through binding.
-		itemsView.items.bindTo( this.items ).using( item => item );
-
-		return itemsView;
-	}
-
-	/**
-	 * 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.
-	 *
-	 * **Note:** Invoked on demand. See {@link #shouldGroupWhenFull} to learn more.
-	 *
-	 * @protected
-	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
-	 */
-	_createOverflowedItemsDropdown() {
-		const t = this.t;
-		const locale = this.locale;
-		const groupedItemsDropdown = createDropdown( locale );
-
-		groupedItemsDropdown.class = 'ck-toolbar__grouped-dropdown';
-		addToolbarToDropdown( groupedItemsDropdown, [] );
-
-		groupedItemsDropdown.buttonView.set( {
-			label: t( 'Show more items' ),
-			tooltip: true,
-			icon: verticalDotsIcon
-		} );
-
-		this.groupedItems = groupedItemsDropdown.toolbarView.items;
-
-		return groupedItemsDropdown;
-	}
-
-	/**
-	 * Handles forward keyboard navigation in the toolbar.
+	 * The opposite of {@link #_ungroupFirstItem}.
 	 *
-	 * Because the internal structure of the toolbar has 2 levels, this cannot be handled
-	 * by a simple {@link module:ui/focuscycler~FocusCycler} instance.
+	 * When called it will remove the last item from {@link #ungroupedItems} and move it to the
+	 * {@link #groupedItems} collection.
 	 *
-	 *	┌────────────────────────────── #_components ────────────────────────────────────────┐
-	 *	|                                                                                    |
-	 *	|    /────▶────\                  /───────▶───────▶───────\          /────▶─────\    |
-	 *	|    |         ▼                  ▲                       ▼          ▲          |    |
-	 *	|    |       ┌─|──── #items ──────|─┐             ┌───────|──────────|───────┐  |    |
-	 *	|    ▲       | \───▶──────────▶───/ |             |   #groupedItemsDropdown  |  ▼    |
-	 *	|    |       └─────────────────────-┘             └──────────────────────────┘  |    |
-	 *	|    |                                                                          |    |
-	 *	|    └─────◀───────────◀────────────◀──────────────◀──────────────◀─────────────/    |
-	 *	|                                                                                    |
-	 *	+────────────────────────────────────────────────────────────────────────────────────┘
+	 * @private
 	 */
-	_focusNext( keyEvtData, cancel ) {
-		const itemsFocusCycler = this._itemsFocusCycler;
-		const componentsFocusCycler = this._componentsFocusCycler;
-
-		if ( this.itemsView.focusTracker.isFocused ) {
-			if ( !itemsFocusCycler.next || itemsFocusCycler.next === itemsFocusCycler.first ) {
-				componentsFocusCycler.focusNext();
-			} else {
-				itemsFocusCycler.focusNext();
-			}
-
-			cancel();
-		} else {
-			componentsFocusCycler.focusNext();
-
-			cancel();
+	_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 );
 	}
 
 	/**
-	 * Handles backward keyboard navigation in the toolbar.
+	 * The opposite of {@link #_groupLastItem}.
 	 *
-	 * Because the internal structure of the toolbar has 2 levels, this cannot be handled
-	 * by a simple {@link module:ui/focuscycler~FocusCycler} instance.
+	 * Moves the very first item from the toolbar belonging to {@link #groupedItems} back
+	 * to the {@link #ungroupedItems} collection.
 	 *
-	 *	┌────────────────────────────── #_components ────────────────────────────────────────┐
-	 *	|                                                                                    |
-	 *	|    /────◀────\                  /───────◀───────◀───────\          /────◀─────\    |
-	 *	|    |         ▲                  ▼                       ▲          ▼          |    |
-	 *	|    |       ┌─|──── #items ──────|─┐             ┌───────|──────────|───────┐  |    |
-	 *	|    ▼       | \───◀──────────◀───/ |             |   #groupedItemsDropdown  |  ▲    |
-	 *	|    |       └─────────────────────-┘             └──────────────────────────┘  |    |
-	 *	|    |                                                                          |    |
-	 *	|    └─────▶───────────▶────────────▶──────────────▶──────────────▶─────────────/    |
-	 *	|                                                                                    |
-	 *	+────────────────────────────────────────────────────────────────────────────────────┘
+	 * @private
 	 */
-	_focusPrevious( keyEvtData, cancel ) {
-		const itemsFocusCycler = this._itemsFocusCycler;
-		const componentsFocusCycler = this._componentsFocusCycler;
-
-		if ( this.itemsView.focusTracker.isFocused ) {
-			const hasGroupedItems = this.groupedItems && this.groupedItems.length;
-
-			if ( hasGroupedItems && ( !itemsFocusCycler.previous || itemsFocusCycler.previous === itemsFocusCycler.last ) ) {
-				componentsFocusCycler.focusLast();
-			} else {
-				itemsFocusCycler.focusPrevious();
-			}
-
-			cancel();
-		} else {
-			itemsFocusCycler.focusLast();
+	_ungroupFirstItem() {
+		this.ungroupedItems.add( this.groupedItems.remove( this.groupedItems.first ) );
 
-			cancel();
+		if ( !this.groupedItems.length ) {
+			this.viewChildren.remove( this.groupedItemsDropdown );
+			this.viewChildren.remove( this.viewChildren.last );
+			this.viewFocusTracker.remove( this.groupedItemsDropdown.element );
 		}
 	}
 
 	/**
-	 * Enables the toolbar functionality that prevents its {@link #items} from overflowing (wrapping
-	 * to the next row) when the space becomes scarce. Instead, the toolbar items are moved to the
-	 * {@link #groupedItems} collection and displayed in a {@link #groupedItemsDropdown} at the end of
-	 * the space, which has its own nested toolbar.
-	 *
-	 * When called, the toolbar will automatically analyze the location of its {@link #items} 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.
+	 * 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.
 	 *
-	 * **Note:** Calling this method **before** the toolbar {@link #element} is in a DOM tree and visible (i.e.
-	 * not `display: none`) will cause lots of warnings in the console from the utilities analyzing
-	 * the geometry of the toolbar items — they depend on the toolbar to be visible in DOM.
+	 * @private
+	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
 	 */
-	_enableOverflowedItemsGroupingOnResize() {
-		let previousWidth;
+	_createGroupedItemsDropdown() {
+		const locale = this.viewLocale;
+		const t = locale.t;
+		const dropdown = createDropdown( locale );
 
-		// TODO: Consider debounce.
-		this._groupWhenFullResizeObserver = getResizeObserver( ( [ entry ] ) => {
-			if ( !previousWidth || previousWidth !== entry.contentRect.width ) {
-				this.updateGroupedItems();
+		dropdown.class = 'ck-toolbar__grouped-dropdown';
+		addToolbarToDropdown( dropdown, [] );
 
-				previousWidth = entry.contentRect.width;
-			}
+		dropdown.buttonView.set( {
+			label: t( 'Show more items' ),
+			tooltip: true,
+			icon: verticalDotsIcon
 		} );
 
-		this._groupWhenFullResizeObserver.observe( this.element );
+		// 1:1 pass–through binding.
+		dropdown.toolbarView.items.bindTo( this.groupedItems ).using( item => item );
 
-		this.updateGroupedItems();
+		return dropdown;
 	}
 
 	/**
-	 * The opposite of {@link #_ungroupFirstItem}.
+	 * 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.
 	 *
-	 * When called it will remove the last item from {@link #items} and move it to the
-	 * {@link #groupedItems} collection (from {@link #itemsView} to {@link #groupedItemsDropdown}).
+	 * 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.
 	 *
-	 * If the {@link #groupedItemsDropdown} does not exist, it is created and added to {@link #_components}.
+	 * See the {@link module:ui/toolbar/toolbarview~ToolbarView#focusables collection} documentation
+	 * to learn more about the purpose of this method.
 	 *
-	 * @protected
+	 * @private
 	 */
-	_groupLastItem() {
-		if ( !this.groupedItemsDropdown ) {
-			this.groupedItemsDropdown = this._createOverflowedItemsDropdown();
-		}
-
-		if ( !this._components.has( this.groupedItemsDropdown ) ) {
-			this._components.add( new ToolbarSeparatorView() );
-			this._components.add( this.groupedItemsDropdown );
-		}
-
-		this.groupedItems.add( this.items.remove( this.items.last ), 0 );
-	}
+	_updateFocusCycleableItems() {
+		this.viewFocusables.clear();
 
-	/**
-	 * The opposite of {@link #_groupLastItem}.
-	 *
-	 * Moves the very first item from the toolbar belonging to {@link #groupedItems} back
-	 * to the {@link #items} collection (from {@link #groupedItemsDropdown} to {@link #itemsView}).
-	 *
-	 * @protected
-	 */
-	_ungroupFirstItem() {
-		this.items.add( this.groupedItems.remove( this.groupedItems.first ) );
+		this.ungroupedItems.map( item => {
+			this.viewFocusables.add( item );
+		} );
 
-		if ( !this.groupedItems.length ) {
-			this._components.remove( this.groupedItemsDropdown );
-			this._components.remove( this._components.last );
+		if ( this.groupedItems.length ) {
+			this.viewFocusables.add( this.groupedItemsDropdown );
 		}
 	}
 }
 
 /**
- * An inner block of the {@link module:ui/toolbar/toolbarview~ToolbarView} hosting its
- * {@link module:ui/toolbar/toolbarview~ToolbarView#items}.
+ * Options passed to the {@link module:ui/toolbar/toolbarview~ToolbarView#constructor} of the toolbar.
  *
- * @private
- * @extends module:ui/view~View
+ * @interface module:ui/toolbar/toolbarview~ToolbarOptions
  */
-class ToolbarItemsView extends View {
-	/**
-	 * @inheritDoc
-	 */
-	constructor( locale ) {
-		super( locale );
-
-		/**
-		 * Collection of the items (buttons, drop–downs, etc.).
-		 *
-		 * @readonly
-		 * @member {module:ui/viewcollection~ViewCollection}
-		 */
-		this.items = this.createCollection();
-
-		/**
-		 * Tracks information about DOM focus in the items view.
-		 *
-		 * @readonly
-		 * @member {module:utils/focustracker~FocusTracker}
-		 */
-		this.focusTracker = new FocusTracker();
 
-		/**
-		 * Helps cycling over focusable {@link #items} in the toolbar.
-		 *
-		 * @readonly
-		 * @protected
-		 * @member {module:ui/focuscycler~FocusCycler}
-		 */
-		this._focusCycler = new FocusCycler( {
-			focusables: this.items,
-			focusTracker: this.focusTracker,
-		} );
-
-		this.setTemplate( {
-			tag: 'div',
-			attributes: {
-				class: [
-					'ck',
-					'ck-toolbar__items'
-				],
-			},
-			children: this.items
-		} );
-	}
+/**
+ * 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
+ */
 
-	/**
-	 * @inheritDoc
-	 */
-	render() {
-		super.render();
+/**
+ * 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
+ */
 
-		this.items.on( 'add', ( evt, item ) => {
-			this.focusTracker.add( item.element );
-		} );
+/**
+ * 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.
+ */
 
-		this.items.on( 'remove', ( evt, item ) => {
-			this.focusTracker.remove( item.element );
-		} );
-	}
+/**
+ * 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.
+ */
 
-	/**
-	 * Focuses the first focusable in {@link #items}.
-	 */
-	focus() {
-		this._focusCycler.focusFirst();
-	}
-}
+/**
+ * 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
+ */

+ 3 - 3
packages/ckeditor5-ui/tests/manual/icon/icon.js

@@ -12,7 +12,7 @@ const icon = `<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg">
 	5.002v9.996C17 15.55 16.55 16 15.993 16H4.007A1.01 1.01 0 0 1 3 14.998V5.002zm1.024
 	10H16v-3.096l-2.89-4.263-3.096 5.257-3.003-2.103L4 13.96l.024 1.043zM6.406 6A1.4 1.4 0 0 0 5
 	7.393a1.4 1.4 0 0 0 1.406 1.393 1.4 1.4 0 0 0 1.407-1.393A1.4 1.4 0 0 0 6.406 6z"
-	fill="#454545" fill-rule="evenodd"/>
+	fill-rule="evenodd"/>
 </svg>`;
 
 const iconDirty = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
@@ -25,7 +25,7 @@ const iconDirty = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 	5.002v9.996C17 15.55 16.55 16 15.993 16H4.007A1.01 1.01 0 0 1 3 14.998V5.002zm1.024
 	10H16v-3.096l-2.89-4.263-3.096 5.257-3.003-2.103L4 13.96l.024 1.043zM6.406 6A1.4 1.4 0 0 0 5
 	7.393a1.4 1.4 0 0 0 1.406 1.393 1.4 1.4 0 0 0 1.407-1.393A1.4 1.4 0 0 0 6.406 6z"
-	fill="#454545" fill-rule="evenodd"/>
+	fill-rule="evenodd"/>
 </svg>`;
 
 const iconViewBox = `<svg viewBox="0 0 35 35" xmlns="http://www.w3.org/2000/svg">
@@ -33,7 +33,7 @@ const iconViewBox = `<svg viewBox="0 0 35 35" xmlns="http://www.w3.org/2000/svg"
 0-3.49-3.51H6.99A3.5 3.5 0 0 0 3.5 8.76v17.48zM5.25 8.753C5.25 7.787 6.038 7 7.012 7h20.976a1.767 1.767 0 0
 1 1.762 1.753v17.494c0 .965-.788 1.753-1.762 1.753H7.012a1.767 1.767 0 0 1-1.762-1.753V8.753zm1.792
 17.5H28v-5.419l-5.058-7.46-5.418 9.2-5.255-3.68L7 24.43l.042 1.825v-.002zM11.21 10.5a2.45 2.45 0 0 0-2.46
-2.438 2.45 2.45 0 0 0 2.46 2.438 2.45 2.45 0 0 0 2.463-2.438A2.45 2.45 0 0 0 11.21 10.5z" fill="#454545"
+2.438 2.45 2.45 0 0 0 2.46 2.438 2.45 2.45 0 0 0 2.463-2.438A2.45 2.45 0 0 0 11.21 10.5z"
 fill-rule="evenodd"/></svg>`;
 
 const ui = testUtils.createTestUIView( {

+ 385 - 448
packages/ckeditor5-ui/tests/toolbar/toolbarview.js

@@ -53,18 +53,28 @@ describe( 'ToolbarView', () => {
 			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#items collection', () => {
 			expect( view.items ).to.be.instanceOf( ViewCollection );
 		} );
 
-		it( 'should not create view#groupedItems collection', () => {
-			expect( view.groupedItems ).to.be.null;
-		} );
-
 		it( 'creates #focusTracker instance', () => {
 			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
 		} );
@@ -77,96 +87,16 @@ describe( 'ToolbarView', () => {
 			expect( view.itemsView ).to.be.instanceOf( View );
 		} );
 
-		it( 'should not create view#groupedItemsDropdown', () => {
-			expect( view.groupedItemsDropdown ).to.be.null;
-		} );
-
-		it( 'should set view#shouldGroupWhenFull', () => {
-			expect( view.shouldGroupWhenFull ).to.be.false;
-		} );
-
-		it( 'should create view#_components collection', () => {
-			expect( view._components ).to.be.instanceOf( ViewCollection );
+		it( 'should create view#children collection', () => {
+			expect( view.children ).to.be.instanceOf( ViewCollection );
 		} );
 
-		it( 'creates #_itemsFocusCycler instance', () => {
-			expect( view._itemsFocusCycler ).to.be.instanceOf( FocusCycler );
+		it( 'creates #_focusCycler instance', () => {
+			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
 		} );
 
-		it( 'creates #_componentsFocusCycler instance', () => {
-			expect( view._componentsFocusCycler ).to.be.instanceOf( FocusCycler );
-		} );
-
-		describe( '#shouldGroupWhenFull', () => {
-			it( 'updates the state of grouped items immediatelly when set true', () => {
-				sinon.spy( view, 'updateGroupedItems' );
-
-				view.shouldGroupWhenFull = true;
-
-				sinon.assert.calledOnce( view.updateGroupedItems );
-			} );
-
-			it( 'updates the state of grouped items after the element is updated in DOM', () => {
-				let hasClassBeforeUpdate;
-
-				sinon.stub( view, 'updateGroupedItems' ).callsFake( () => {
-					hasClassBeforeUpdate = view.element.classList.contains( 'ck-toolbar_grouping' );
-				} );
-
-				view.shouldGroupWhenFull = true;
-
-				expect( hasClassBeforeUpdate ).to.be.true;
-			} );
-
-			// Possibly in the future a possibility to turn the automatic grouping off could be required.
-			// As for now, there is no such need, so there is no such functionality.
-			it( 'does nothing if toggled false', () => {
-				view.shouldGroupWhenFull = true;
-
-				expect( () => {
-					view.shouldGroupWhenFull = false;
-				} ).to.not.throw();
-			} );
-
-			it( 'starts observing toolbar resize immediatelly when set true', () => {
-				function FakeResizeObserver( callback ) {
-					this.callback = callback;
-				}
-
-				FakeResizeObserver.prototype.observe = sinon.spy();
-				FakeResizeObserver.prototype.disconnect = sinon.spy();
-
-				testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( FakeResizeObserver );
-
-				expect( view._groupWhenFullResizeObserver ).to.be.null;
-
-				view.shouldGroupWhenFull = true;
-
-				sinon.assert.calledOnce( view._groupWhenFullResizeObserver.observe );
-				sinon.assert.calledWithExactly( view._groupWhenFullResizeObserver.observe, view.element );
-			} );
-
-			it( 'updates the state of grouped items upon resize', () => {
-				sinon.spy( view, 'updateGroupedItems' );
-
-				function FakeResizeObserver( callback ) {
-					this.callback = callback;
-				}
-
-				FakeResizeObserver.prototype.observe = sinon.spy();
-				FakeResizeObserver.prototype.disconnect = sinon.spy();
-
-				testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( FakeResizeObserver );
-
-				expect( view._groupWhenFullResizeObserver ).to.be.null;
-
-				view.shouldGroupWhenFull = true;
-				view._groupWhenFullResizeObserver.callback( [
-					{ contentRect: { width: 42 } }
-				] );
-
-				sinon.assert.calledTwice( view.updateGroupedItems );
-			} );
+		it( 'creates #_behavior', () => {
+			expect( view._behavior ).to.be.an( 'object' );
 		} );
 	} );
 
@@ -224,14 +154,6 @@ describe( 'ToolbarView', () => {
 
 	describe( 'element bindings', () => {
 		describe( 'class', () => {
-			it( 'reacts on view#isVertical', () => {
-				view.isVertical = false;
-				expect( view.element.classList.contains( 'ck-toolbar_vertical' ) ).to.be.false;
-
-				view.isVertical = true;
-				expect( view.element.classList.contains( 'ck-toolbar_vertical' ) ).to.be.true;
-			} );
-
 			it( 'reacts on view#class', () => {
 				view.class = 'foo';
 				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
@@ -243,33 +165,24 @@ describe( 'ToolbarView', () => {
 				expect( view.element.classList.contains( 'foo' ) ).to.be.false;
 				expect( view.element.classList.contains( 'bar' ) ).to.be.false;
 			} );
-
-			it( 'reacts on view#shouldGroupWhenFull', () => {
-				view.shouldGroupWhenFull = false;
-				expect( view.element.classList.contains( 'ck-toolbar_grouping' ) ).to.be.false;
-
-				view.shouldGroupWhenFull = true;
-				expect( view.element.classList.contains( 'ck-toolbar_grouping' ) ).to.be.true;
-			} );
 		} );
 	} );
 
 	describe( 'render()', () => {
-		it( 'registers #_components in #focusTracker', () => {
+		it( 'registers #items in #focusTracker', () => {
 			const view = new ToolbarView( locale );
 			const spyAdd = sinon.spy( view.focusTracker, 'add' );
 			const spyRemove = sinon.spy( view.focusTracker, 'remove' );
 
-			view._components.add( focusable() );
-			view._components.add( focusable() );
+			view.items.add( focusable() );
+			view.items.add( focusable() );
 			sinon.assert.notCalled( spyAdd );
 
 			view.render();
 
-			// First call is for the #itemsView.
-			sinon.assert.calledThrice( spyAdd );
+			sinon.assert.calledTwice( spyAdd );
 
-			view._components.remove( 1 );
+			view.items.remove( 1 );
 			sinon.assert.calledOnce( spyRemove );
 
 			view.destroy();
@@ -308,8 +221,8 @@ describe( 'ToolbarView', () => {
 				view.items.add( focusable() );
 
 				// Mock the last item is focused.
-				view.itemsView.focusTracker.isFocused = true;
-				view.itemsView.focusTracker.focusedElement = view.items.get( 4 ).element;
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.items.get( 4 ).element;
 
 				view.keystrokes.press( keyEvtData );
 
@@ -326,8 +239,8 @@ describe( 'ToolbarView', () => {
 				view.items.add( focusable() );
 
 				// Mock the last item is focused.
-				view.itemsView.focusTracker.isFocused = true;
-				view.itemsView.focusTracker.focusedElement = view.items.get( 2 ).element;
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.items.get( 2 ).element;
 
 				view.keystrokes.press( keyEvtData );
 				sinon.assert.calledOnce( view.items.get( 0 ).focus );
@@ -354,8 +267,8 @@ describe( 'ToolbarView', () => {
 				view.items.add( focusable() );
 
 				// Mock the last item is focused.
-				view.itemsView.focusTracker.isFocused = true;
-				view.itemsView.focusTracker.focusedElement = view.items.get( 4 ).element;
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.items.get( 4 ).element;
 
 				view.keystrokes.press( keyEvtData );
 
@@ -372,155 +285,40 @@ describe( 'ToolbarView', () => {
 				view.items.add( focusable() );
 
 				// Mock the last item is focused.
-				view.itemsView.focusTracker.isFocused = true;
-				view.itemsView.focusTracker.focusedElement = view.items.get( 0 ).element;
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.items.get( 0 ).element;
 
 				view.keystrokes.press( keyEvtData );
 				sinon.assert.calledOnce( view.items.get( 2 ).focus );
 			} );
+		} );
 
-			describe( 'when #shouldGroupWhenFull is true', () => {
-				beforeEach( () => {
-					document.body.appendChild( view.element );
-					view.element.style.width = '200px';
-					view.shouldGroupWhenFull = true;
-				} );
-
-				afterEach( () => {
-					view.element.remove();
-				} );
-
-				it( 'navigates from #items to the #groupedItemsDropdown (forwards)', () => {
-					const keyEvtData = getArrowKeyData( 'arrowright' );
-
-					view.items.add( focusable() );
-					view.items.add( nonFocusable() );
-					view.items.add( focusable() );
-
-					view.updateGroupedItems();
-					sinon.spy( view.groupedItemsDropdown, 'focus' );
-
-					view.focusTracker.isFocused = true;
-					view.focusTracker.focusedElement = view.itemsView.element;
-					view.itemsView.focusTracker.isFocused = true;
-					view.itemsView.focusTracker.focusedElement = view.items.get( 0 ).element;
-
-					view.keystrokes.press( keyEvtData );
-
-					sinon.assert.calledOnce( view.groupedItemsDropdown.focus );
-				} );
-
-				it( 'navigates from the #groupedItemsDropdown to #items (forwards)', () => {
-					const keyEvtData = getArrowKeyData( 'arrowright' );
-
-					view.items.add( focusable() );
-					view.items.add( nonFocusable() );
-					view.items.add( focusable() );
-
-					view.updateGroupedItems();
-
-					view.focusTracker.isFocused = true;
-					view.focusTracker.focusedElement = view.groupedItemsDropdown.element;
-					view.itemsView.focusTracker.isFocused = false;
-					view.itemsView.focusTracker.focusedElement = null;
-
-					view.keystrokes.press( keyEvtData );
-
-					sinon.assert.calledOnce( view.items.get( 0 ).focus );
-				} );
-
-				it( 'navigates from #items to the #groupedItemsDropdown (backwards)', () => {
-					const keyEvtData = getArrowKeyData( 'arrowleft' );
-
-					view.items.add( focusable() );
-					view.items.add( nonFocusable() );
-					view.items.add( focusable() );
-
-					view.updateGroupedItems();
-					sinon.spy( view.groupedItemsDropdown, 'focus' );
-
-					view.focusTracker.isFocused = true;
-					view.focusTracker.focusedElement = view.itemsView.element;
-					view.itemsView.focusTracker.isFocused = true;
-					view.itemsView.focusTracker.focusedElement = view.items.get( 0 ).element;
-
-					view.keystrokes.press( keyEvtData );
-
-					sinon.assert.calledOnce( view.groupedItemsDropdown.focus );
-				} );
-
-				it( 'navigates from the #groupedItemsDropdown to #items (backwards)', () => {
-					const keyEvtData = getArrowKeyData( 'arrowleft' );
-
-					view.items.add( focusable() );
-					view.items.add( nonFocusable() );
-					view.items.add( focusable() );
-
-					view.updateGroupedItems();
-
-					view.focusTracker.isFocused = true;
-					view.focusTracker.focusedElement = view.groupedItemsDropdown.element;
-					view.itemsView.focusTracker.isFocused = false;
-					view.itemsView.focusTracker.focusedElement = null;
+		it( 'calls _behavior#render()', () => {
+			const view = new ToolbarView( locale );
+			sinon.spy( view._behavior, 'render' );
 
-					view.keystrokes.press( keyEvtData );
+			view.render();
+			sinon.assert.calledOnce( view._behavior.render );
+			sinon.assert.calledWithExactly( view._behavior.render, view );
 
-					sinon.assert.calledOnce( view.items.get( 0 ).focus );
-				} );
-			} );
+			view.destroy();
 		} );
 	} );
 
 	describe( 'destroy()', () => {
-		it( 'destroys the #groupedItemsDropdown', () => {
-			document.body.appendChild( view.element );
-			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 );
-
-			// The dropdown shows up.
-			view.shouldGroupWhenFull = true;
-			sinon.spy( view.groupedItemsDropdown, 'destroy' );
-
-			view.element.style.width = '500px';
-
-			// The dropdown hides; it does not belong to any collection but it still exist.
-			view.updateGroupedItems();
+		it( 'destroys the feature', () => {
+			sinon.spy( view._behavior, 'destroy' );
 
 			view.destroy();
-			sinon.assert.calledOnce( view.groupedItemsDropdown.destroy );
 
-			view.element.remove();
+			sinon.assert.calledOnce( view._behavior.destroy );
 		} );
 
-		it( 'disconnects the #_groupWhenFullResizeObserver', () => {
-			document.body.appendChild( view.element );
-			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 );
-
-			view.shouldGroupWhenFull = true;
-			sinon.spy( view._groupWhenFullResizeObserver, 'disconnect' );
+		it( 'calls _behavior#destroy()', () => {
+			sinon.spy( view._behavior, 'destroy' );
 
 			view.destroy();
-			sinon.assert.calledOnce( view._groupWhenFullResizeObserver.disconnect );
-			view.element.remove();
+			sinon.assert.calledOnce( view._behavior.destroy );
 		} );
 	} );
 
@@ -538,20 +336,6 @@ describe( 'ToolbarView', () => {
 
 			sinon.assert.calledOnce( view.items.get( 1 ).focus );
 		} );
-
-		it( 'if no items  the first focusable of #items in DOM', () => {
-			document.body.appendChild( view.element );
-			view.element.style.width = '10px';
-
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-
-			view.shouldGroupWhenFull = true;
-			sinon.spy( view.groupedItemsDropdown, 'focus' );
-
-			view.focus();
-			sinon.assert.calledOnce( view.groupedItemsDropdown.focus );
-		} );
 	} );
 
 	describe( 'focusLast()', () => {
@@ -570,24 +354,6 @@ describe( 'ToolbarView', () => {
 
 			sinon.assert.calledOnce( view.items.get( 3 ).focus );
 		} );
-
-		it( 'focuses the #groupedItemsDropdown when view#shouldGroupWhenFull is true', () => {
-			document.body.appendChild( view.element );
-			view.element.style.width = '200px';
-			view.shouldGroupWhenFull = true;
-
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-
-			sinon.spy( view.groupedItemsDropdown, 'focus' );
-
-			view.focusLast();
-
-			sinon.assert.calledOnce( view.groupedItemsDropdown.focus );
-
-			view.element.remove();
-		} );
 	} );
 
 	describe( 'fillFromConfig()', () => {
@@ -630,56 +396,140 @@ describe( 'ToolbarView', () => {
 		} );
 	} );
 
-	describe( 'updateGroupedItems()', () => {
+	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( () => {
-			document.body.appendChild( view.element );
+			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();
 		} );
 
-		it( 'only works when #shouldGroupWhenFull', () => {
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
+		describe( 'constructor()', () => {
+			it( 'extends the template with the CSS class', () => {
+				expect( view.element.classList.contains( 'ck-toolbar_grouping' ) ).to.be.true;
+			} );
 
-			view.updateGroupedItems();
+			it( 'updates the UI as new #items are added', () => {
+				sinon.spy( view._behavior, '_updateGrouping' );
 
-			expect( view.items ).to.have.length( 4 );
-			expect( view.groupedItems ).to.be.null;
-		} );
+				const itemA = focusable();
+				const itemB = focusable();
+				const itemC = focusable();
+				const itemD = focusable();
 
-		it( 'stays silent if the toolbar is detached from visible DOM', () => {
-			testUtils.sinon.spy( console, 'warn' );
-			view.element.remove();
+				view.element.style.width = '200px';
 
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
+				view.items.add( itemA );
+				view.items.add( itemB );
 
-			view.shouldGroupWhenFull = true;
+				sinon.assert.calledTwice( view._behavior._updateGrouping );
 
-			sinon.assert.notCalled( console.warn );
-		} );
+				expect( ungroupedItems ).to.have.length( 2 );
+				expect( groupedItems ).to.have.length( 0 );
 
-		it( 'does not group when items fit', () => {
-			const itemA = focusable();
-			const itemB = focusable();
+				view.items.add( itemC );
 
-			view.items.add( itemA );
-			view.items.add( itemB );
+				// The dropdown took some extra space.
+				expect( ungroupedItems ).to.have.length( 1 );
+				expect( groupedItems ).to.have.length( 2 );
 
-			view.shouldGroupWhenFull = true;
+				view.items.add( itemD, 2 );
 
-			expect( view.groupedItems ).to.be.null;
-			expect( view.groupedItemsDropdown ).to.be.null;
+				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 #groupedItemsDropdown', () => {
+		it( 'groups items that overflow into the dropdown', () => {
 			const itemA = focusable();
 			const itemB = focusable();
 			const itemC = focusable();
@@ -690,17 +540,15 @@ describe( 'ToolbarView', () => {
 			view.items.add( itemC );
 			view.items.add( itemD );
 
-			view.shouldGroupWhenFull = true;
-
-			expect( view.items.map( i => i ) ).to.have.members( [ itemA ] );
-			expect( view.groupedItems.map( i => i ) ).to.have.members( [ itemB, itemC, itemD ] );
-			expect( view._components ).to.have.length( 3 );
-			expect( view._components.get( 0 ) ).to.equal( view.itemsView );
-			expect( view._components.get( 1 ) ).to.be.instanceOf( ToolbarSeparatorView );
-			expect( view._components.get( 2 ) ).to.equal( view.groupedItemsDropdown );
+			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 from #groupedItemsDropdown if there is enough space to display them (all)', () => {
+		it( 'ungroups items if there is enough space to display them (all)', () => {
 			const itemA = focusable();
 			const itemB = focusable();
 			const itemC = focusable();
@@ -711,21 +559,19 @@ describe( 'ToolbarView', () => {
 			view.items.add( itemC );
 			view.items.add( itemD );
 
-			view.shouldGroupWhenFull = true;
-
-			expect( view.items.map( i => i ) ).to.have.members( [ itemA ] );
-			expect( view.groupedItems.map( i => i ) ).to.have.members( [ itemB, itemC, 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 #groupedItems after an attempt was made.
-			view.updateGroupedItems();
-			expect( view.items.map( i => i ) ).to.have.members( [ itemA, itemB, itemC ] );
-			expect( view.groupedItems.map( i => i ) ).to.have.members( [ itemD ] );
+			// 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 from #groupedItemsDropdown if there is enough space to display them (some)', () => {
+		it( 'ungroups items if there is enough space to display them (some)', () => {
 			const itemA = focusable();
 			const itemB = focusable();
 			const itemC = focusable();
@@ -734,50 +580,207 @@ describe( 'ToolbarView', () => {
 			view.items.add( itemB );
 			view.items.add( itemC );
 
-			view.shouldGroupWhenFull = true;
-
-			expect( view.items.map( i => i ) ).to.have.members( [ itemA ] );
-			expect( view.groupedItems.map( i => i ) ).to.have.members( [ itemB, 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.updateGroupedItems();
-			expect( view.items.map( i => i ) ).to.have.members( [ itemA, itemB, itemC ] );
-			expect( view.groupedItems ).to.have.length( 0 );
-			expect( view._components ).to.have.length( 1 );
-			expect( view._components.get( 0 ) ).to.equal( view.itemsView );
+			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( '#groupedItemsDropdown', () => {
-			it( 'has proper DOM structure', () => {
+		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();
 
-				view.shouldGroupWhenFull = true;
+				testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( FakeResizeObserver );
+
+				const view = new ToolbarView( locale, {
+					shouldGroupWhenFull: true
+				} );
+
+				testUtils.sinon.spy( view._behavior, '_updateGrouping' );
+
+				view.render();
 
-				const dropdown = view.groupedItemsDropdown;
+				view._behavior.resizeObserver.callback( [
+					{ contentRect: { width: 42 } }
+				] );
+
+				sinon.assert.calledTwice( view._behavior._updateGrouping );
 
-				expect( view._components.has( view.groupedItemsDropdown ) ).to.be.true;
-				expect( dropdown.element.classList.contains( 'ck-toolbar__grouped-dropdown' ) );
-				expect( dropdown.buttonView.label ).to.equal( 'Show more items' );
+				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 );
 
-			it( 'shares its toolbarView#items with ToolbarView#groupedItems', () => {
+				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() );
 
-				view.shouldGroupWhenFull = true;
+				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( view.groupedItemsDropdown.toolbarView.items ).to.equal( view.groupedItems );
+				expect( groupedItemsDropdown.toolbarView.items.map( i => i ) )
+					.to.have.ordered.members( groupedItems.map( i => i ) );
 			} );
 		} );
 
-		describe( '#items overflow checking logic', () => {
+		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';
@@ -787,14 +790,14 @@ describe( 'ToolbarView', () => {
 				view.items.add( focusable() );
 				view.items.add( focusable() );
 
-				view.shouldGroupWhenFull = true;
-
-				expect( view.groupedItems ).to.have.length( 1 );
+				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 );
+				const view = new ToolbarView( locale, {
+					shouldGroupWhenFull: true
+				} );
 
 				view.extendTemplate( {
 					attributes: {
@@ -813,116 +816,50 @@ describe( 'ToolbarView', () => {
 				view.items.add( focusable() );
 				view.items.add( focusable() );
 
-				view.shouldGroupWhenFull = true;
-
-				expect( view.groupedItems ).to.have.length( 1 );
+				expect( view._behavior.groupedItems ).to.have.length( 1 );
 
 				view.destroy();
 				view.element.remove();
 			} );
 		} );
-	} );
-
-	describe( 'automatic toolbar grouping (#shouldGroupWhenFull = true)', () => {
-		beforeEach( () => {
-			document.body.appendChild( view.element );
-			view.element.style.width = '200px';
-		} );
 
-		afterEach( () => {
-			view.element.remove();
-		} );
-
-		it( 'updates the UI as new #items are added', () => {
-			sinon.spy( view, 'updateGroupedItems' );
-			sinon.assert.notCalled( view.updateGroupedItems );
-
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			sinon.assert.calledTwice( view.updateGroupedItems );
-		} );
-
-		it( 'updates the UI as #items are removed', () => {
-			sinon.spy( view, 'updateGroupedItems' );
-			sinon.assert.notCalled( view.updateGroupedItems );
+		describe( 'focus management', () => {
+			it( '#focus() focuses the dropdown when it is the only focusable', () => {
+				sinon.spy( groupedItemsDropdown, 'focus' );
+				view.element.style.width = '10px';
 
-			view.items.add( focusable() );
-			sinon.assert.calledOnce( view.updateGroupedItems );
+				const itemA = focusable();
+				const itemB = focusable();
 
-			view.items.remove( 0 );
-			sinon.assert.calledTwice( view.updateGroupedItems );
-		} );
+				view.items.add( itemA );
+				view.items.add( itemB );
 
-		it( 'updates the UI when the toolbar is being resized (expanding)', done => {
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-
-			view.element.style.width = '200px';
-			view.shouldGroupWhenFull = true;
-
-			expect( view.items ).to.have.length( 1 );
-			expect( view.groupedItems ).to.have.length( 4 );
-
-			view.element.style.width = '500px';
-
-			setTimeout( () => {
-				expect( view.items ).to.have.length( 5 );
-				expect( view.groupedItems ).to.have.length( 0 );
-
-				done();
-			}, 100 );
-		} );
+				expect( view.focusables.map( i => i ) ).to.have.ordered.members( [ groupedItemsDropdown ] );
 
-		it( 'updates the UI when the toolbar is being resized (narrowing)', done => {
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-
-			view.element.style.width = '500px';
-			view.shouldGroupWhenFull = true;
-
-			expect( view.items ).to.have.length( 5 );
-			expect( view.groupedItems ).to.be.null;
-
-			view.element.style.width = '200px';
-
-			setTimeout( () => {
-				expect( view.items ).to.have.length( 1 );
-				expect( view.groupedItems ).to.have.length( 4 );
+				view.focus();
+				sinon.assert.calledOnce( groupedItemsDropdown.focus );
+			} );
 
-				done();
-			}, 100 );
-		} );
+			it( '#focusLast() focuses the dropdown when present', () => {
+				sinon.spy( groupedItemsDropdown, 'focus' );
+				view.element.style.width = '200px';
 
-		it( 'does not react to changes in height', done => {
-			view.element.style.width = '500px';
-			view.element.style.height = '200px';
+				const itemA = focusable();
+				const itemB = focusable();
+				const itemC = focusable();
 
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
-			view.items.add( focusable() );
+				view.items.add( itemA );
+				view.items.add( itemB );
+				view.items.add( itemC );
 
-			view.shouldGroupWhenFull = true;
-			sinon.spy( view, 'updateGroupedItems' );
+				expect( view.focusables.map( i => i ) ).to.have.ordered.members( [ itemA, groupedItemsDropdown ] );
 
-			expect( view.items ).to.have.length( 5 );
-			expect( view.groupedItems ).to.be.null;
+				view.focusLast();
 
-			setTimeout( () => {
-				view.element.style.height = '500px';
+				sinon.assert.calledOnce( groupedItemsDropdown.focus );
 
-				setTimeout( () => {
-					sinon.assert.calledOnce( view.updateGroupedItems );
-					done();
-				}, 100 );
-			}, 100 );
+				view.element.remove();
+			} );
 		} );
 	} );
 } );