8
0
Aleksander Nowodzinski 6 лет назад
Родитель
Сommit
44c076a4f9
1 измененных файлов с 329 добавлено и 407 удалено
  1. 329 407
      packages/ckeditor5-ui/src/toolbar/toolbarview.js

+ 329 - 407
packages/ckeditor5-ui/src/toolbar/toolbarview.js

@@ -46,13 +46,25 @@ export default class ToolbarView extends View {
 	 * Also see {@link #render}.
 	 *
 	 * @param {module:utils/locale~Locale} locale The localization services instance.
+	 * @param {Object} [options]
+	 * @param {Boolean} [options.shouldGroupWhenFull] 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.
 	 */
-	constructor( locale ) {
+	constructor( locale, options = {} ) {
 		super( locale );
 
 		const bind = this.bindTemplate;
 		const t = this.t;
 
+		/**
+		 * TODO
+		 *
+		 * @readonly
+		 * @member {Object}
+		 */
+		this.options = options;
+
 		/**
 		 * Label used by assistive technologies to describe this toolbar element.
 		 *
@@ -73,17 +85,6 @@ export default class ToolbarView extends View {
 		 */
 		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,32 +102,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.
 		 *
@@ -144,73 +119,51 @@ 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.
+		 * TODO
 		 *
-		 * 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
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
 		 */
-		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' } );
+		this._ungroupedItems = this.createCollection();
 
 		/**
-		 * 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.
+		 * TODO
 		 *
-		 * **Note:** Created dynamically only when {@link #shouldGroupWhenFull} is `true`.
+		 * 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
-		 * @protected
-		 * @member {module:utils/dom/getresizeobserver~ResizeObserver}
+		 * @member {module:ui/viewcollection~ViewCollection}
 		 */
-		this._groupWhenFullResizeObserver = null;
+		this._groupedItems = this.createCollection();
 
 		/**
-		 * 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 view containing toolbar {@link #items}.
 		 *
-		 * **Note:** Used only when {@link #shouldGroupWhenFull} is `true`.
+		 * **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
-		 * @private
-		 * @member {Boolean}
+		 * @member {module:ui/toolbar/toolbarview~UngrouppedItemsView}
 		 */
-		this._groupWhenFullLock = false;
+		this._ungroupedItemsView = this._createUngrouppedItemsView();
 
 		/**
-		 * 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.
+		 * 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:** Set only when {@link #shouldGroupWhenFull} is `true`.
+		 * **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
-		 * @private
-		 * @member {Number}
+		 * @member {module:ui/dropdown/dropdownview~DropdownView} #groupedItemsDropdown
 		 */
-		this._groupWhenFullCachedPadding = null;
+		this._groupedItemsDropdown = this._createGrouppedItemsDropdown();
 
 		/**
 		 * A top–level collection aggregating building blocks of the toolbar. It mainly exists to
@@ -224,42 +177,37 @@ export default class ToolbarView extends View {
 		 * @member {module:ui/viewcollection~ViewCollection}
 		 */
 		this._components = this.createCollection();
-		this._components.add( this.itemsView );
+		this._components.add( this._ungroupedItemsView );
 
 		/**
-		 * Helps cycling over focusable {@link #items} in the toolbar residing in the {@link #itemsView}.
-		 *
-		 * 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}
+		 * TODO
 		 */
-		this._itemsFocusCycler = new FocusCycler( {
-			focusables: this.itemsView.items,
-			focusTracker: this.itemsView.focusTracker,
-		} );
+		this._focusCycleableItems = this.createCollection();
+
+		this._ungroupedItems.on( 'add', this._updateFocusCycleableItems.bind( this ) );
+		this._ungroupedItems.on( 'remove', this._updateFocusCycleableItems.bind( this ) );
+		this._components.on( 'add', this._updateFocusCycleableItems.bind( this ) );
+		this._components.on( 'remove', this._updateFocusCycleableItems.bind( this ) );
 
 		/**
-		 * 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 focusable {@link #items} in the toolbar.
 		 *
 		 * @readonly
 		 * @protected
 		 * @member {module:ui/focuscycler~FocusCycler}
 		 */
-		this._componentsFocusCycler = new FocusCycler( {
-			focusables: this._components,
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusCycleableItems,
 			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,8 +215,8 @@ export default class ToolbarView extends View {
 				class: [
 					'ck',
 					'ck-toolbar',
+					options.shouldGroupWhenFull ? 'ck-toolbar_grouping' : '',
 					bind.if( 'isVertical', 'ck-toolbar_vertical' ),
-					bind.if( 'shouldGroupWhenFull', 'ck-toolbar_grouping' ),
 					bind.to( 'class' )
 				],
 				role: 'toolbar',
@@ -291,28 +239,41 @@ export default class ToolbarView extends View {
 		super.render();
 
 		// Components added before rendering should be known to the #focusTracker.
-		for ( const component of this._components ) {
-			this.focusTracker.add( component.element );
+		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._itemsGroupper = new ToolbarItemsGroupper( {
+			shouldGroupWhenFull: this.options.shouldGroupWhenFull,
+			items: this.items,
+			ungroupedItems: this._ungroupedItems,
+			groupedItems: this._groupedItems,
+			toolbarElement: this.element,
+			uiLanguageDirection: this.locale.uiLanguageDirection,
+
+			onGroupStart: () => {
+				this._components.add( new ToolbarSeparatorView() );
+				this._components.add( this._groupedItemsDropdown );
+				this.focusTracker.add( this._groupedItemsDropdown.element );
+			},
+
+			onGroupEnd: () => {
+				this._components.remove( this._groupedItemsDropdown );
+				this._components.remove( this._components.last );
+				this.focusTracker.remove( this._groupedItemsDropdown.element );
+			}
+		} );
 	}
 
 	/**
@@ -321,39 +282,25 @@ export default class ToolbarView extends View {
 	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();
-		}
+		this._groupedItemsDropdown.destroy();
 
-		if ( this._groupWhenFullResizeObserver ) {
-			this._groupWhenFullResizeObserver.disconnect();
-		}
+		this._itemsGroupper.destroy();
 
 		return super.destroy();
 	}
 
 	/**
-	 * Focuses the first focusable in element in the toolbar.
+	 * Focuses the first focusable in {@link #items}.
 	 */
 	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 #items}.
 	 */
 	focusLast() {
-		const last = this._componentsFocusCycler.last;
-
-		if ( last === this.itemsView ) {
-			this.itemsView._focusCycler.focusLast();
-		} else {
-			this._componentsFocusCycler.focusLast();
-		}
+		this._focusCycler.focusLast();
 	}
 
 	/**
@@ -396,124 +343,19 @@ export default class ToolbarView 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.
-	 *
-	 * 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.
-	 */
-	updateGroupedItems() {
-		if ( !this.shouldGroupWhenFull ) {
-			return;
-		}
-
-		// 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;
-		}
-
-		// 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 ) ) {
-			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,
-		// 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 && 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();
-			}
-		}
-
-		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.
-	 *
-	 * **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`.
-	 *
-	 * @protected
-	 * @type {Boolean}
-	 */
-	get _areItemsOverflowing() {
-		// An empty toolbar cannot overflow.
-		if ( !this.items.length ) {
-			return false;
-		}
-
-		const uiLanguageDirection = this.locale.uiLanguageDirection;
-		const lastChildRect = new Rect( this.element.lastChild );
-		const toolbarRect = new Rect( this.element );
-
-		if ( !this._groupWhenFullCachedPadding ) {
-			const computedStyle = global.window.getComputedStyle( this.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 ] );
-		}
-
-		if ( uiLanguageDirection === 'ltr' ) {
-			return lastChildRect.right > toolbarRect.right - this._groupWhenFullCachedPadding;
-		} else {
-			return lastChildRect.left < toolbarRect.left + this._groupWhenFullCachedPadding;
-		}
-	}
-
 	/**
 	 * 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 );
+	_createUngrouppedItemsView() {
+		const ungrouppedItemsView = new UngrouppedItemsView( this.locale );
 
 		// 1:1 pass–through binding.
-		itemsView.items.bindTo( this.items ).using( item => item );
+		ungrouppedItemsView.items.bindTo( this._ungroupedItems ).using( item => item );
 
-		return itemsView;
+		return ungrouppedItemsView;
 	}
 
 	/**
@@ -525,7 +367,11 @@ export default class ToolbarView extends View {
 	 * @protected
 	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
 	 */
-	_createOverflowedItemsDropdown() {
+	_createGrouppedItemsDropdown() {
+		if ( !this.options.shouldGroupWhenFull ) {
+			return null;
+		}
+
 		const t = this.t;
 		const locale = this.locale;
 		const groupedItemsDropdown = createDropdown( locale );
@@ -539,85 +385,200 @@ export default class ToolbarView extends View {
 			icon: verticalDotsIcon
 		} );
 
-		this.groupedItems = groupedItemsDropdown.toolbarView.items;
+		groupedItemsDropdown.toolbarView.items.bindTo( this._groupedItems ).using( item => item );
 
 		return groupedItemsDropdown;
 	}
 
 	/**
-	 * Handles forward keyboard navigation in the toolbar.
-	 *
-	 * Because the internal structure of the toolbar has 2 levels, this cannot be handled
-	 * by a simple {@link module:ui/focuscycler~FocusCycler} instance.
-	 *
-	 *	┌────────────────────────────── #_components ────────────────────────────────────────┐
-	 *	|                                                                                    |
-	 *	|    /────▶────\                  /───────▶───────▶───────\          /────▶─────\    |
-	 *	|    |         ▼                  ▲                       ▼          ▲          |    |
-	 *	|    |       ┌─|──── #items ──────|─┐             ┌───────|──────────|───────┐  |    |
-	 *	|    ▲       | \───▶──────────▶───/ |             |   #groupedItemsDropdown  |  ▼    |
-	 *	|    |       └─────────────────────-┘             └──────────────────────────┘  |    |
-	 *	|    |                                                                          |    |
-	 *	|    └─────◀───────────◀────────────◀──────────────◀──────────────◀─────────────/    |
-	 *	|                                                                                    |
-	 *	+────────────────────────────────────────────────────────────────────────────────────┘
+	 * TODO
 	 */
-	_focusNext( keyEvtData, cancel ) {
-		const itemsFocusCycler = this._itemsFocusCycler;
-		const componentsFocusCycler = this._componentsFocusCycler;
+	_updateFocusCycleableItems() {
+		this._focusCycleableItems.clear();
+
+		this._ungroupedItems.map( item => {
+			this._focusCycleableItems.add( item );
+		} );
+
+		if ( this._groupedItemsDropdown && this._components.has( this._groupedItemsDropdown ) ) {
+			this._focusCycleableItems.add( this._groupedItemsDropdown );
+		}
+	}
+}
+
+/**
+ * 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 UngrouppedItemsView 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();
+
+		this.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-toolbar__items'
+				],
+			},
+			children: this.items
+		} );
+	}
+}
+
+class ToolbarItemsGroupper {
+	constructor( options ) {
+		Object.assign( this, options );
 
-		if ( this.itemsView.focusTracker.isFocused ) {
-			if ( !itemsFocusCycler.next || itemsFocusCycler.next === itemsFocusCycler.first ) {
-				componentsFocusCycler.focusNext();
+		this.items.on( 'add', ( evt, item, index ) => {
+			if ( index > this.ungroupedItems.length ) {
+				this.groupedItems.add( item, index - this.ungroupedItems.length );
 			} else {
-				itemsFocusCycler.focusNext();
+				this.ungroupedItems.add( item, index );
 			}
 
-			cancel();
-		} else {
-			componentsFocusCycler.focusNext();
+			if ( options.shouldGroupWhenFull ) {
+				this.update();
+			}
+		} );
 
-			cancel();
+		this.items.on( 'remove', ( evt, item ) => {
+			if ( this.groupedItems.has( item ) ) {
+				this.groupedItems.remove( item );
+			} else if ( this.ungroupedItems.has( item ) ) {
+				this.ungroupedItems.remove( item );
+			}
+
+			if ( options.shouldGroupWhenFull ) {
+				this.update();
+			}
+		} );
+
+		if ( options.shouldGroupWhenFull ) {
+			this.enableGroupingOnResize();
 		}
+
+		/**
+		 * 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`.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:utils/dom/getresizeobserver~ResizeObserver}
+		 */
+		this._resizeObserver = null;
+
+		/**
+		 * A flag used by {@link #update} method to make sure no concurrent updates
+		 * are performed to the {@link #items} and {@link #groupedItems}. Because {@link #update}
+		 * manages those collections but also is executed upon changes in those collections, this flag
+		 * ensures no infinite loops occur.
+		 *
+		 * **Note:** Used only when {@link #shouldGroupWhenFull} is `true`.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {Boolean}
+		 */
+		this._updateLock = false;
+
+		/**
+		 * A cached value of the horizontal padding style used by {@link #update}
+		 * 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.
+		 *
+		 * **Note:** Set only when {@link #shouldGroupWhenFull} is `true`.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {Number}
+		 */
+		this._cachedPadding = null;
 	}
 
 	/**
-	 * Handles backward keyboard navigation in the toolbar.
-	 *
-	 * 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, 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.
 	 *
-	 *	┌────────────────────────────── #_components ────────────────────────────────────────┐
-	 *	|                                                                                    |
-	 *	|    /────◀────\                  /───────◀───────◀───────\          /────◀─────\    |
-	 *	|    |         ▲                  ▼                       ▲          ▼          |    |
-	 *	|    |       ┌─|──── #items ──────|─┐             ┌───────|──────────|───────┐  |    |
-	 *	|    ▼       | \───◀──────────◀───/ |             |   #groupedItemsDropdown  |  ▲    |
-	 *	|    |       └─────────────────────-┘             └──────────────────────────┘  |    |
-	 *	|    |                                                                          |    |
-	 *	|    └─────▶───────────▶────────────▶──────────────▶──────────────▶─────────────/    |
-	 *	|                                                                                    |
-	 *	+────────────────────────────────────────────────────────────────────────────────────┘
+	 * 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.
 	 */
-	_focusPrevious( keyEvtData, cancel ) {
-		const itemsFocusCycler = this._itemsFocusCycler;
-		const componentsFocusCycler = this._componentsFocusCycler;
+	update() {
+		if ( !this.shouldGroupWhenFull ) {
+			return;
+		}
 
-		if ( this.itemsView.focusTracker.isFocused ) {
-			const hasGroupedItems = this.groupedItems && this.groupedItems.length;
+		// 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._updateLock ) {
+			return;
+		}
 
-			if ( hasGroupedItems && ( !itemsFocusCycler.previous || itemsFocusCycler.previous === itemsFocusCycler.last ) ) {
-				componentsFocusCycler.focusLast();
-			} else {
-				itemsFocusCycler.focusPrevious();
-			}
+		// 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.toolbarElement.ownerDocument.body.contains( this.toolbarElement ) ) {
+			return;
+		}
 
-			cancel();
-		} else {
-			itemsFocusCycler.focusLast();
+		// 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._updateLock = true;
+
+		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.areToolbarItemsOverflowing ) {
+			this.groupLastItem();
 
-			cancel();
+			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 && this.groupedItems.length ) {
+			// Ungroup items as long as none are overflowing or there are none to ungroup left.
+			while ( this.groupedItems.length && !this.areToolbarItemsOverflowing ) {
+				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.areToolbarItemsOverflowing ) {
+				this.groupLastItem();
+			}
 		}
+
+		this._updateLock = false;
 	}
 
 	/**
@@ -635,137 +596,98 @@ export default class ToolbarView extends View {
 	 * 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.
 	 */
-	_enableOverflowedItemsGroupingOnResize() {
+	enableGroupingOnResize() {
 		let previousWidth;
 
 		// TODO: Consider debounce.
-		this._groupWhenFullResizeObserver = getResizeObserver( ( [ entry ] ) => {
+		this._resizeObserver = getResizeObserver( ( [ entry ] ) => {
 			if ( !previousWidth || previousWidth !== entry.contentRect.width ) {
-				this.updateGroupedItems();
+				this.update();
 
 				previousWidth = entry.contentRect.width;
 			}
 		} );
 
-		this._groupWhenFullResizeObserver.observe( this.element );
+		this._resizeObserver.observe( this.toolbarElement );
 
-		this.updateGroupedItems();
+		this.update();
+	}
+
+	destroy() {
+		if ( this._resizeObserver ) {
+			this._resizeObserver.disconnect();
+		}
 	}
 
 	/**
-	 * The opposite of {@link #_ungroupFirstItem}.
-	 *
-	 * 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}).
+	 * Returns `true` when any of toolbar {@link #items} visually overflows, for instance if the
+	 * toolbar is narrower than its members. `false` otherwise.
 	 *
-	 * If the {@link #groupedItemsDropdown} does not exist, it is created and added to {@link #_components}.
+	 * **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`.
 	 *
 	 * @protected
+	 * @type {Boolean}
 	 */
-	_groupLastItem() {
-		if ( !this.groupedItemsDropdown ) {
-			this.groupedItemsDropdown = this._createOverflowedItemsDropdown();
+	get areToolbarItemsOverflowing() {
+		// An empty toolbar cannot overflow.
+		if ( !this.ungroupedItems.length ) {
+			return false;
 		}
 
-		if ( !this._components.has( this.groupedItemsDropdown ) ) {
-			this._components.add( new ToolbarSeparatorView() );
-			this._components.add( this.groupedItemsDropdown );
+		const uiLanguageDirection = this.uiLanguageDirection;
+		const lastChildRect = new Rect( this.toolbarElement.lastChild );
+		const toolbarRect = new Rect( this.toolbarElement );
+
+		if ( !this._cachedPadding ) {
+			const computedStyle = global.window.getComputedStyle( this.toolbarElement );
+			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 ] );
 		}
 
-		this.groupedItems.add( this.items.remove( this.items.last ), 0 );
+		if ( uiLanguageDirection === 'ltr' ) {
+			return lastChildRect.right > toolbarRect.right - this._cachedPadding;
+		} else {
+			return lastChildRect.left < toolbarRect.left + this._cachedPadding;
+		}
 	}
 
 	/**
-	 * The opposite of {@link #_groupLastItem}.
+	 * The opposite of {@link #ungroupFirstItem}.
 	 *
-	 * Moves the very first item from the toolbar belonging to {@link #groupedItems} back
-	 * to the {@link #items} collection (from {@link #groupedItemsDropdown} to {@link #itemsView}).
+	 * 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}).
+	 *
+	 * If the {@link #groupedItemsDropdown} does not exist, it is created and added to {@link #_components}.
 	 *
 	 * @protected
 	 */
-	_ungroupFirstItem() {
-		this.items.add( this.groupedItems.remove( this.groupedItems.first ) );
-
+	groupLastItem() {
 		if ( !this.groupedItems.length ) {
-			this._components.remove( this.groupedItemsDropdown );
-			this._components.remove( this._components.last );
+			this.onGroupStart();
 		}
-	}
-}
-
-/**
- * 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 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
-		} );
+		this.groupedItems.add( this.ungroupedItems.remove( this.ungroupedItems.last ), 0 );
 	}
 
 	/**
-	 * @inheritDoc
+	 * 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
 	 */
-	render() {
-		super.render();
+	ungroupFirstItem() {
+		this.ungroupedItems.add( this.groupedItems.remove( this.groupedItems.first ) );
 
-		this.items.on( 'add', ( evt, item ) => {
-			this.focusTracker.add( item.element );
-		} );
-
-		this.items.on( 'remove', ( evt, item ) => {
-			this.focusTracker.remove( item.element );
-		} );
-	}
-
-	/**
-	 * Focuses the first focusable in {@link #items}.
-	 */
-	focus() {
-		this._focusCycler.focusFirst();
+		if ( !this.groupedItems.length ) {
+			this.onGroupEnd();
+		}
 	}
 }