From 6fb07363e6ea6201b9702632588132cfb9439f54 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 16 Oct 2018 14:58:05 +0200 Subject: [PATCH 01/39] Implemented ToolbarView#enableWrappedItemsGroupping method to improve editor UX in narrow viewports. --- src/toolbar/toolbarview.js | 236 +++++++++++++++++++++++++++ theme/components/toolbar/toolbar.css | 21 +++ 2 files changed, 257 insertions(+) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index acdf9576..833954fa 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -14,8 +14,14 @@ import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; import ToolbarSeparatorView from './toolbarseparatorview'; import preventDefault from '../bindings/preventdefault.js'; import log from '@ckeditor/ckeditor5-utils/src/log'; +import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; +import { createDropdown, addToolbarToDropdown } from '../dropdown/utils'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import '../../theme/components/toolbar/toolbar.css'; +import doubleRightArrowIcon from '@ckeditor/ckeditor5-core/theme/icons/double-right-arrow.svg'; + +const SUDDEN_SCROLL_GROUP_SAFETY_OFFSET = 25; /** * The toolbar view class. @@ -56,6 +62,19 @@ export default class ToolbarView extends View { */ this.keystrokes = new KeystrokeHandler(); + /** + * The dropdown that aggregates items that wrap to the next line. It is displayed + * at the end of the toolbar and offers a nested toolbar which displays items + * that would normally be wrapped to the next line. + * + * **Note:** It is created on demand when the space in the toolbar is scarce and only + * if {@link #enableWrappedItemsGroupping} has been called for this dropdown. + * + * @readonly + * @member {module:ui/dropdown/dropdownview~DropdownView} #wrappedItemsDropdown + */ + this.wrappedItemsDropdown = null; + /** * Controls the orientation of toolbar items. * @@ -92,6 +111,23 @@ export default class ToolbarView extends View { } } ); + /** + * A map that connects views belonging to {@link #items} with their Rects. + * + * It makes sense only when {@link #enableWrappedItemsGroupping} has been used. + * When a toolbar item lands in the {@link #wrappedItemsDropdown}, it saves the item's + * DOM rect so the algorithm can use it later on to decide if that particular item + * can be "ungroupped" from the dropdown when there's enough space in the toolbar. + * + * Because "groupped" items in the dropdown are invisible, their Rects cannot be obtained, so + * decision about their location is made using the Rect cached in this map beforehand. + * + * @readonly + * @private + * @member {Map.} + */ + this._grouppedItemRects = new Map(); + this.setTemplate( { tag: 'div', attributes: { @@ -135,6 +171,19 @@ export default class ToolbarView extends View { this.keystrokes.listenTo( this.element ); } + /** + * @inheritDoc + */ + destroy() { + // The dropdown may not be in #items at the moment of toolbar destruction + // so let's make sure it's actually destroyed along with the toolbar. + if ( this.wrappedItemsDropdown ) { + this.wrappedItemsDropdown.destroy(); + } + + return super.destroy(); + } + /** * Focuses the first focusable in {@link #items}. */ @@ -187,5 +236,192 @@ export default class ToolbarView extends View { } } ); } + + /** + * Enables the toolbar functionality that prevents its {@link #items} from wrapping to the next line + * when the space becomes scarce. Instead, the toolbar items are grouped under the + * {@link #wrappedItemsDropdown dropdown} displayed at the end of the space, which offers its own + * nested toolbar. + * + * When called, the toolbar will automatically analyze the location of its children 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 to the + * main {@link #element}, depending on the visual space available. + * + * **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. + */ + enableWrappedItemsGroupping() { + this._checkItemsWrappingAndUnwrapping(); + + this.listenTo( global.window, 'resize', () => { + this._checkItemsWrappingAndUnwrapping(); + } ); + } + + /** + * Returns the last of {@link #items} which is not {@link #wrappedItemsDropdown}. + * + * @protected + */ + get _lastRegularItem() { + return this.items.get( this.items.length - 2 ); + } + + /** + * Returns `true` when {@link #wrappedItemsDropdown} exists and currently is in {@link #items}. + * `false` otherwise. + * + * @protected + */ + get _isWrappedItemsDropdownInItems() { + return this.wrappedItemsDropdown && this.items.getIndex( this.wrappedItemsDropdown ) > -1; + } + + /** + * Creates the {@link #wrappedItemsDropdown} on demand. Used when the space in the toolbar + * is scarce and some items start wrapping and need grouping. + * + * See {@link #_groupLastRegularItem}. + * + * @protected + */ + _createWrappedItemsDropdown() { + const t = this.t; + const locale = this.locale; + + this.wrappedItemsDropdown = createDropdown( locale ); + this.wrappedItemsDropdown.class = 'ck-toolbar__groupped-dropdown'; + addToolbarToDropdown( this.wrappedItemsDropdown, [] ); + + this.wrappedItemsDropdown.buttonView.set( { + label: t( 'Show more items' ), + tooltip: true, + icon: doubleRightArrowIcon + } ); + } + + /** + * When called it will remove the last {@link #_lastRegularItem regular item} from {@link #items} + * and move it to the {@link #wrappedItemsDropdown}. + * + * If the dropdown does not exist or does not + * belong to {@link #items} it is created and located at the end of the collection. + * + * @protected + */ + _groupLastRegularItem() { + // Add the groupped list dropdown if not already there. + if ( !this._isWrappedItemsDropdownInItems ) { + if ( !this.wrappedItemsDropdown ) { + this._createWrappedItemsDropdown(); + } + + this.items.add( this.wrappedItemsDropdown ); + } + + const lastItem = this._lastRegularItem; + + this._grouppedItemRects.set( lastItem, new Rect( lastItem.element ) ); + + this.wrappedItemsDropdown.toolbarView.items.add( this.items.remove( lastItem ), 0 ); + } + + /** + * Moves the very first item from the toolbar belonging to {@link #wrappedItemsDropdown} back + * to the {@link #items} collection. + * + * In some way, it's the opposite of {@link #_groupLastRegularItem}. + * + * @protected + */ + _ungroupFirstGrouppedItem() { + this.items.add( this.wrappedItemsDropdown.toolbarView.items.remove( 0 ), this.items.length - 1 ); + } + + /** + * When called it will try to moves the very first item from the toolbar belonging to {@link #wrappedItemsDropdown} + * back to the {@link #items} collection. + * + * Whether the items is moved or not, it depends on the remaining space in the toolbar, which is + * verified using {@link #_grouppedItemRects}. + * + * @protected + */ + _tryUngroupLastItem() { + const firstGrouppedItem = this.wrappedItemsDropdown.toolbarView.items.get( 0 ); + const firstGrouppedItemRect = this._grouppedItemRects.get( firstGrouppedItem ); + const wrappedItemsDropdownRect = new Rect( this.wrappedItemsDropdown.element ); + const lastItemRect = new Rect( this._lastRegularItem.element ); + + // If there's only one grouped item, then when ungrouped, it should replace the wrapped items + // dropdown. Consider that fact when analyzing rects, because the conditions are different. + if ( this.wrappedItemsDropdown.toolbarView.items.length === 1 ) { + if ( lastItemRect.right + firstGrouppedItemRect.width + SUDDEN_SCROLL_GROUP_SAFETY_OFFSET < wrappedItemsDropdownRect.right ) { + this._ungroupFirstGrouppedItem(); + this.items.remove( this.wrappedItemsDropdown ); + } + } else if ( lastItemRect.right + firstGrouppedItemRect.width + SUDDEN_SCROLL_GROUP_SAFETY_OFFSET < wrappedItemsDropdownRect.left ) { + this._ungroupFirstGrouppedItem(); + } + } + + /** + * Returns `true` when any of toolbar {@link #items} wrapped visually to the next line. + * `false` otherwise. + * + * @protected + */ + get _areItemsWrapping() { + let previousItemRect; + + for ( const item of this.items ) { + const itemRect = new Rect( item.element ); + + if ( previousItemRect ) { + if ( itemRect.top > previousItemRect.bottom ) { + return true; + } + } + + previousItemRect = itemRect; + } + + return false; + } + + /** + * When called it will check if any of the {@link #items} wraps to the next line and if so, it will + * move it to the {@link #wrappedItemsDropdown}. + * + * At the same time, it will also check if there is enough space in the toolbar for the first of the + * "grouped" items in the {@link #wrappedItemsDropdown} to be returned back. + * + * @protected + */ + _checkItemsWrappingAndUnwrapping() { + if ( !this.element || !this.element.parentNode ) { + return; + } + + while ( this._areItemsWrapping ) { + this._groupLastRegularItem(); + } + + if ( this._isWrappedItemsDropdownInItems ) { + // Post-fixing just in case the page content grows up and a scrollbar appears. + // If the last item is too close to the wrapped items dropdown, put it in the + // dropdown too: if scrollbar shows up, it could push the dropdown to the next line. + const wrappedItemsDropdownRect = new Rect( this.wrappedItemsDropdown.element ); + const lastItemRect = new Rect( this._lastRegularItem.element ); + + if ( lastItemRect.right + SUDDEN_SCROLL_GROUP_SAFETY_OFFSET > wrappedItemsDropdownRect.left ) { + this._groupLastRegularItem(); + } + + this._tryUngroupLastItem(); + } + } } diff --git a/theme/components/toolbar/toolbar.css b/theme/components/toolbar/toolbar.css index a8c2c058..452d92ac 100644 --- a/theme/components/toolbar/toolbar.css +++ b/theme/components/toolbar/toolbar.css @@ -29,3 +29,24 @@ display: block; width: 100%; } + +/* Pull the groupped items dropdown to the right */ +.ck.ck-toolbar__groupped-dropdown { + margin-left: auto; + + /* + * Dropdown button has asymmetric padding to fit the arrow. + * This button has no arrow so let's revert that padding back to normal. + */ + & > .ck.ck-button.ck-dropdown__button { + padding-left: var(--ck-spacing-tiny); + } + + & > .ck-dropdown__button .ck-dropdown__arrow { + display: none; + } + + & > .ck.ck-dropdown__panel > .ck-toolbar { + flex-wrap: nowrap; + } +} From ebdc70c5c44f58cbfb79bdf5cc767c02c5cc4161 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Fri, 2 Aug 2019 16:05:43 +0200 Subject: [PATCH 02/39] Some improvements to the PoC. --- src/toolbar/toolbarview.js | 70 ++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 607f092f..133a69f9 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -19,11 +19,15 @@ import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import { createDropdown, addToolbarToDropdown } from '../dropdown/utils'; import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; -import doubleRightArrowIcon from '@ckeditor/ckeditor5-core/theme/icons/double-right-arrow.svg'; +import verticalDotsIcon from '@ckeditor/ckeditor5-core/theme/icons/three-vertical-dots.svg'; import '../../theme/components/toolbar/toolbar.css'; -const SUDDEN_SCROLL_GROUP_SAFETY_OFFSET = 25; +// This is the offset for the enableWrappedItemsGroupping() method. It estimates the width of the +// scrollbar. There's no way to tell when the vertical page scrollbar appears using the DOM API so +// when wrapping toolbar items to the next line we must consider it may show up at any time +// (e.g. user wrote more content). This is the h–distance the scrollbar will consume when it appears. +const SUDDEN_SCROLL_SAFETY_OFFSET = 25; /** * The toolbar view class. @@ -266,7 +270,19 @@ export default class ToolbarView extends View { * @protected */ get _lastRegularItem() { - return this.items.get( this.items.length - 2 ); + if ( this._isWrappedItemsDropdownInItems ) { + if ( this.items.length > 1 ) { + return this.items.get( this.items.length - 2 ); + } else { + return null; + } + } else { + if ( this.items.length ) { + return this.items.last; + } else { + return null; + } + } } /** @@ -276,7 +292,7 @@ export default class ToolbarView extends View { * @protected */ get _isWrappedItemsDropdownInItems() { - return this.wrappedItemsDropdown && this.items.getIndex( this.wrappedItemsDropdown ) > -1; + return this.wrappedItemsDropdown && this.items.has( this.wrappedItemsDropdown ); } /** @@ -298,7 +314,7 @@ export default class ToolbarView extends View { this.wrappedItemsDropdown.buttonView.set( { label: t( 'Show more items' ), tooltip: true, - icon: doubleRightArrowIcon + icon: verticalDotsIcon } ); } @@ -353,16 +369,23 @@ export default class ToolbarView extends View { const firstGrouppedItem = this.wrappedItemsDropdown.toolbarView.items.get( 0 ); const firstGrouppedItemRect = this._grouppedItemRects.get( firstGrouppedItem ); const wrappedItemsDropdownRect = new Rect( this.wrappedItemsDropdown.element ); - const lastItemRect = new Rect( this._lastRegularItem.element ); + const lastRegularItem = this._lastRegularItem; + let leftBoundary; + + if ( lastRegularItem ) { + leftBoundary = new Rect( lastRegularItem.element ).right; + } else { + leftBoundary = new Rect( this.element ).left; + } // If there's only one grouped item, then when ungrouped, it should replace the wrapped items // dropdown. Consider that fact when analyzing rects, because the conditions are different. if ( this.wrappedItemsDropdown.toolbarView.items.length === 1 ) { - if ( lastItemRect.right + firstGrouppedItemRect.width + SUDDEN_SCROLL_GROUP_SAFETY_OFFSET < wrappedItemsDropdownRect.right ) { + if ( leftBoundary + firstGrouppedItemRect.width + SUDDEN_SCROLL_SAFETY_OFFSET < wrappedItemsDropdownRect.right ) { this._ungroupFirstGrouppedItem(); this.items.remove( this.wrappedItemsDropdown ); } - } else if ( lastItemRect.right + firstGrouppedItemRect.width + SUDDEN_SCROLL_GROUP_SAFETY_OFFSET < wrappedItemsDropdownRect.left ) { + } else if ( leftBoundary + firstGrouppedItemRect.width + SUDDEN_SCROLL_SAFETY_OFFSET < wrappedItemsDropdownRect.left ) { this._ungroupFirstGrouppedItem(); } } @@ -374,21 +397,20 @@ export default class ToolbarView extends View { * @protected */ get _areItemsWrapping() { - let previousItemRect; - - for ( const item of this.items ) { - const itemRect = new Rect( item.element ); + if ( !this.items.length ) { + return false; + } - if ( previousItemRect ) { - if ( itemRect.top > previousItemRect.bottom ) { - return true; - } - } + const firstItem = this.items.first; - previousItemRect = itemRect; + if ( this.items.length === 1 ) { + return false; } - return false; + const firstItemRect = new Rect( firstItem.element ); + const lastItemRect = new Rect( this.items.last.element ); + + return firstItemRect.bottom < lastItemRect.top; } /** @@ -414,10 +436,14 @@ export default class ToolbarView extends View { // If the last item is too close to the wrapped items dropdown, put it in the // dropdown too: if scrollbar shows up, it could push the dropdown to the next line. const wrappedItemsDropdownRect = new Rect( this.wrappedItemsDropdown.element ); - const lastItemRect = new Rect( this._lastRegularItem.element ); + const lastRegularItem = this._lastRegularItem; - if ( lastItemRect.right + SUDDEN_SCROLL_GROUP_SAFETY_OFFSET > wrappedItemsDropdownRect.left ) { - this._groupLastRegularItem(); + if ( lastRegularItem ) { + const lastRegularItemRect = new Rect( lastRegularItem.element ); + + if ( lastRegularItemRect.right + SUDDEN_SCROLL_SAFETY_OFFSET > wrappedItemsDropdownRect.left ) { + this._groupLastRegularItem(); + } } this._tryUngroupLastItem(); From b203e12e4cd3ff4fbb96f26ace5222763fc811a2 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 5 Aug 2019 13:50:50 +0200 Subject: [PATCH 03/39] Another PoC. --- src/rectobserver.js | 67 +++++++++++++ src/toolbar/toolbarview.js | 143 +++++++++------------------ theme/components/toolbar/toolbar.css | 4 + 3 files changed, 115 insertions(+), 99 deletions(-) create mode 100644 src/rectobserver.js diff --git a/src/rectobserver.js b/src/rectobserver.js new file mode 100644 index 00000000..956acaaa --- /dev/null +++ b/src/rectobserver.js @@ -0,0 +1,67 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/RectObserver + */ + +/* globals setTimeout, clearTimeout, ResizeObserver */ + +import mix from '@ckeditor/ckeditor5-utils/src/mix'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; +import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; +import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin'; + +export default class RectObserver { + constructor( element ) { + this.element = element; + } + + observe( callback ) { + if ( typeof ResizeObserver === 'function' ) { + this._observer = new ResizeObserver( entries => { + callback( new Rect( entries[ 0 ].contentRect ) ); + } ).observe( this.element ); + } else { + let previousRect; + + const hasRectChanged = () => { + const currentRect = new Rect( this.element ); + const hasChanged = previousRect && !previousRect.isEqual( currentRect ); + + previousRect = currentRect; + + return hasChanged; + }; + + const periodicCheck = () => { + if ( hasRectChanged() ) { + callback( previousRect ); + } + + this._checkTimeout = setTimeout( periodicCheck, 500 ); + }; + + this.listenTo( global.window, 'resize', () => { + if ( hasRectChanged() ) { + callback( previousRect ); + } + } ); + + periodicCheck(); + } + } + + stopObserving() { + if ( this._observer ) { + this._observer.disconnect(); + } else { + this.stopListening(); + clearTimeout( this._checkTimeout ); + } + } +} + +mix( RectObserver, DomEmitterMixin ); diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 133a69f9..66944877 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -14,21 +14,15 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import FocusCycler from '../focuscycler'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; import ToolbarSeparatorView from './toolbarseparatorview'; +import RectObserver from '../rectobserver'; import preventDefault from '../bindings/preventdefault.js'; import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; -import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import { createDropdown, addToolbarToDropdown } from '../dropdown/utils'; import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import verticalDotsIcon from '@ckeditor/ckeditor5-core/theme/icons/three-vertical-dots.svg'; import '../../theme/components/toolbar/toolbar.css'; -// This is the offset for the enableWrappedItemsGroupping() method. It estimates the width of the -// scrollbar. There's no way to tell when the vertical page scrollbar appears using the DOM API so -// when wrapping toolbar items to the next line we must consider it may show up at any time -// (e.g. user wrote more content). This is the h–distance the scrollbar will consume when it appears. -const SUDDEN_SCROLL_SAFETY_OFFSET = 25; - /** * The toolbar view class. * @@ -257,49 +251,29 @@ export default class ToolbarView extends View { * the geometry of the toolbar items — they depend on the toolbar to be visible in DOM. */ enableWrappedItemsGroupping() { - this._checkItemsWrappingAndUnwrapping(); + let oldRect; - this.listenTo( global.window, 'resize', () => { - this._checkItemsWrappingAndUnwrapping(); - } ); - } + this._checkItemsWrappingAndUnwrapping(); - /** - * Returns the last of {@link #items} which is not {@link #wrappedItemsDropdown}. - * - * @protected - */ - get _lastRegularItem() { - if ( this._isWrappedItemsDropdownInItems ) { - if ( this.items.length > 1 ) { - return this.items.get( this.items.length - 2 ); - } else { - return null; + // TODO: stopObserving on destroy(); + new RectObserver( this.element ).observe( newRect => { + if ( oldRect && oldRect.width !== newRect.width ) { + this._checkItemsWrappingAndUnwrapping(); } - } else { - if ( this.items.length ) { - return this.items.last; - } else { - return null; - } - } + + oldRect = newRect; + } ); } - /** - * Returns `true` when {@link #wrappedItemsDropdown} exists and currently is in {@link #items}. - * `false` otherwise. - * - * @protected - */ - get _isWrappedItemsDropdownInItems() { - return this.wrappedItemsDropdown && this.items.has( this.wrappedItemsDropdown ); + get _grouppedItems() { + return this.wrappedItemsDropdown.toolbarView.items; } /** * Creates the {@link #wrappedItemsDropdown} on demand. Used when the space in the toolbar * is scarce and some items start wrapping and need grouping. * - * See {@link #_groupLastRegularItem}. + * See {@link #_groupLastItem}. * * @protected */ @@ -319,7 +293,7 @@ export default class ToolbarView extends View { } /** - * When called it will remove the last {@link #_lastRegularItem regular item} from {@link #items} + * When called it will remove the last {@link #_lastNonGrouppedItem regular item} from {@link #items} * and move it to the {@link #wrappedItemsDropdown}. * * If the dropdown does not exist or does not @@ -327,66 +301,39 @@ export default class ToolbarView extends View { * * @protected */ - _groupLastRegularItem() { - // Add the groupped list dropdown if not already there. - if ( !this._isWrappedItemsDropdownInItems ) { - if ( !this.wrappedItemsDropdown ) { - this._createWrappedItemsDropdown(); - } + _groupLastItem() { + if ( !this.wrappedItemsDropdown ) { + this._createWrappedItemsDropdown(); + } + if ( !this.items.has( this.wrappedItemsDropdown ) ) { this.items.add( this.wrappedItemsDropdown ); } - const lastItem = this._lastRegularItem; + let lastNonGrouppedItem; - this._grouppedItemRects.set( lastItem, new Rect( lastItem.element ) ); + if ( this.items.has( this.wrappedItemsDropdown ) ) { + lastNonGrouppedItem = this.items.length > 1 ? this.items.get( this.items.length - 2 ) : null; + } else { + lastNonGrouppedItem = this.items.last; + } - this.wrappedItemsDropdown.toolbarView.items.add( this.items.remove( lastItem ), 0 ); + this._grouppedItems.add( this.items.remove( lastNonGrouppedItem ), 0 ); } /** * Moves the very first item from the toolbar belonging to {@link #wrappedItemsDropdown} back * to the {@link #items} collection. * - * In some way, it's the opposite of {@link #_groupLastRegularItem}. - * - * @protected - */ - _ungroupFirstGrouppedItem() { - this.items.add( this.wrappedItemsDropdown.toolbarView.items.remove( 0 ), this.items.length - 1 ); - } - - /** - * When called it will try to moves the very first item from the toolbar belonging to {@link #wrappedItemsDropdown} - * back to the {@link #items} collection. - * - * Whether the items is moved or not, it depends on the remaining space in the toolbar, which is - * verified using {@link #_grouppedItemRects}. + * In some way, it's the opposite of {@link #_groupLastItem}. * * @protected */ - _tryUngroupLastItem() { - const firstGrouppedItem = this.wrappedItemsDropdown.toolbarView.items.get( 0 ); - const firstGrouppedItemRect = this._grouppedItemRects.get( firstGrouppedItem ); - const wrappedItemsDropdownRect = new Rect( this.wrappedItemsDropdown.element ); - const lastRegularItem = this._lastRegularItem; - let leftBoundary; - - if ( lastRegularItem ) { - leftBoundary = new Rect( lastRegularItem.element ).right; - } else { - leftBoundary = new Rect( this.element ).left; - } + _ungroupFirstItem() { + this.items.add( this._grouppedItems.remove( 0 ), this.items.length - 1 ); - // If there's only one grouped item, then when ungrouped, it should replace the wrapped items - // dropdown. Consider that fact when analyzing rects, because the conditions are different. - if ( this.wrappedItemsDropdown.toolbarView.items.length === 1 ) { - if ( leftBoundary + firstGrouppedItemRect.width + SUDDEN_SCROLL_SAFETY_OFFSET < wrappedItemsDropdownRect.right ) { - this._ungroupFirstGrouppedItem(); - this.items.remove( this.wrappedItemsDropdown ); - } - } else if ( leftBoundary + firstGrouppedItemRect.width + SUDDEN_SCROLL_SAFETY_OFFSET < wrappedItemsDropdownRect.left ) { - this._ungroupFirstGrouppedItem(); + if ( !this._grouppedItems.length ) { + this.items.remove( this.wrappedItemsDropdown ); } } @@ -401,12 +348,11 @@ export default class ToolbarView extends View { return false; } - const firstItem = this.items.first; - if ( this.items.length === 1 ) { return false; } + const firstItem = this.items.first; const firstItemRect = new Rect( firstItem.element ); const lastItemRect = new Rect( this.items.last.element ); @@ -427,26 +373,25 @@ export default class ToolbarView extends View { return; } + let wereItemsGroupped; + while ( this._areItemsWrapping ) { - this._groupLastRegularItem(); + this._groupLastItem(); + wereItemsGroupped = true; } - if ( this._isWrappedItemsDropdownInItems ) { - // Post-fixing just in case the page content grows up and a scrollbar appears. - // If the last item is too close to the wrapped items dropdown, put it in the - // dropdown too: if scrollbar shows up, it could push the dropdown to the next line. - const wrappedItemsDropdownRect = new Rect( this.wrappedItemsDropdown.element ); - const lastRegularItem = this._lastRegularItem; + if ( !wereItemsGroupped && this.wrappedItemsDropdown && this._grouppedItems.length ) { + while ( !this._areItemsWrapping ) { + this._ungroupFirstItem(); - if ( lastRegularItem ) { - const lastRegularItemRect = new Rect( lastRegularItem.element ); - - if ( lastRegularItemRect.right + SUDDEN_SCROLL_SAFETY_OFFSET > wrappedItemsDropdownRect.left ) { - this._groupLastRegularItem(); + if ( !this._grouppedItems.length ) { + break; } } - this._tryUngroupLastItem(); + if ( this._areItemsWrapping ) { + this._groupLastItem(); + } } } } diff --git a/theme/components/toolbar/toolbar.css b/theme/components/toolbar/toolbar.css index 05f0793f..4d648fca 100644 --- a/theme/components/toolbar/toolbar.css +++ b/theme/components/toolbar/toolbar.css @@ -5,6 +5,10 @@ @import "../../mixins/_unselectable.css"; +:root { + --ck-toolbar-groupped-dropdown-arrow-size: calc(0.5 * var(--ck-icon-size)); +} + .ck.ck-toolbar { @mixin ck-unselectable; From 26fcfea68a035b55b6057980c1fdc980444ff0d2 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 6 Aug 2019 16:00:42 +0200 Subject: [PATCH 04/39] Stabilized the toolbar grouping PoC. --- src/toolbar/toolbarview.js | 129 ++++++++++++++++++--------- theme/components/toolbar/toolbar.css | 8 +- 2 files changed, 89 insertions(+), 48 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 66944877..c8fb2a14 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -68,7 +68,7 @@ export default class ToolbarView extends View { * that would normally be wrapped to the next line. * * **Note:** It is created on demand when the space in the toolbar is scarce and only - * if {@link #enableWrappedItemsGroupping} has been called for this dropdown. + * if {@link #enableWrappedItemsGrouping} has been called for this dropdown. * * @readonly * @member {module:ui/dropdown/dropdownview~DropdownView} #wrappedItemsDropdown @@ -91,6 +91,14 @@ export default class ToolbarView extends View { */ this.set( 'class' ); + /** + * TODO + * + * @observable + * @member {Boolean} #isGrouping + */ + this.set( 'isGrouping', false ); + /** * Helps cycling over focusable {@link #items} in the toolbar. * @@ -111,22 +119,7 @@ export default class ToolbarView extends View { } } ); - /** - * A map that connects views belonging to {@link #items} with their Rects. - * - * It makes sense only when {@link #enableWrappedItemsGroupping} has been used. - * When a toolbar item lands in the {@link #wrappedItemsDropdown}, it saves the item's - * DOM rect so the algorithm can use it later on to decide if that particular item - * can be "ungroupped" from the dropdown when there's enough space in the toolbar. - * - * Because "groupped" items in the dropdown are invisible, their Rects cannot be obtained, so - * decision about their location is made using the Rect cached in this map beforehand. - * - * @readonly - * @private - * @member {Map.} - */ - this._grouppedItemRects = new Map(); + this._itemsWrappingLock = false; this.setTemplate( { tag: 'div', @@ -159,16 +152,34 @@ export default class ToolbarView extends View { this.focusTracker.add( item.element ); } - this.items.on( 'add', ( evt, item ) => { + this.items.on( 'add', ( evt, item, index ) => { this.focusTracker.add( item.element ); + + if ( item !== this.wrappedItemsDropdown ) { + const wrappedItemsDropdownIndex = this.items.getIndex( this.wrappedItemsDropdown ); + + // Post–fixing if a new item was added after the wrapped items dropdown (pushed into #items). + // The dropdown must always be the last one no matter what. + if ( wrappedItemsDropdownIndex > -1 && index > wrappedItemsDropdownIndex ) { + this.items.add( this.items.remove( this.wrappedItemsDropdown ) ); + } + + this._groupOrUngroupWrappingItems( true ); + } } ); this.items.on( 'remove', ( evt, item ) => { this.focusTracker.remove( item.element ); + + if ( item !== this.wrappedItemsDropdown ) { + this._groupOrUngroupWrappingItems(); + } } ); // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); + + this._enableWrappedItemsGroupingOnResize(); } /** @@ -206,11 +217,14 @@ export default class ToolbarView extends View { * @param {module:ui/componentfactory~ComponentFactory} factory A factory producing toolbar items. */ fillFromConfig( config, factory ) { - config.map( name => { + // 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 wrap to the next line would be grouped + // in a revere order. + config.reverse().map( name => { if ( name == '|' ) { - this.items.add( new ToolbarSeparatorView() ); + this.items.add( new ToolbarSeparatorView(), 0 ); } else if ( factory.has( name ) ) { - this.items.add( factory.create( name ) ); + this.items.add( factory.create( name ), 0 ); } else { /** * There was a problem processing the configuration of the toolbar. The item with the given @@ -250,22 +264,22 @@ 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. */ - enableWrappedItemsGroupping() { + _enableWrappedItemsGroupingOnResize() { let oldRect; - this._checkItemsWrappingAndUnwrapping(); + this._groupOrUngroupWrappingItems(); // TODO: stopObserving on destroy(); new RectObserver( this.element ).observe( newRect => { if ( oldRect && oldRect.width !== newRect.width ) { - this._checkItemsWrappingAndUnwrapping(); + this._groupOrUngroupWrappingItems(); } oldRect = newRect; } ); } - get _grouppedItems() { + get _groupedItems() { return this.wrappedItemsDropdown.toolbarView.items; } @@ -282,7 +296,7 @@ export default class ToolbarView extends View { const locale = this.locale; this.wrappedItemsDropdown = createDropdown( locale ); - this.wrappedItemsDropdown.class = 'ck-toolbar__groupped-dropdown'; + this.wrappedItemsDropdown.class = 'ck-toolbar__grouped-dropdown'; addToolbarToDropdown( this.wrappedItemsDropdown, [] ); this.wrappedItemsDropdown.buttonView.set( { @@ -292,8 +306,12 @@ export default class ToolbarView extends View { } ); } + get _hasWrappedItemsDropdown() { + return this.wrappedItemsDropdown && this.items.has( this.wrappedItemsDropdown ); + } + /** - * When called it will remove the last {@link #_lastNonGrouppedItem regular item} from {@link #items} + * When called it will remove the last {@link #_lastNonGroupedItem regular item} from {@link #items} * and move it to the {@link #wrappedItemsDropdown}. * * If the dropdown does not exist or does not @@ -306,19 +324,19 @@ export default class ToolbarView extends View { this._createWrappedItemsDropdown(); } - if ( !this.items.has( this.wrappedItemsDropdown ) ) { + if ( !this._hasWrappedItemsDropdown ) { this.items.add( this.wrappedItemsDropdown ); } - let lastNonGrouppedItem; + let lastNonGroupedItem; - if ( this.items.has( this.wrappedItemsDropdown ) ) { - lastNonGrouppedItem = this.items.length > 1 ? this.items.get( this.items.length - 2 ) : null; + if ( this._hasWrappedItemsDropdown ) { + lastNonGroupedItem = this.items.length > 1 ? this.items.get( this.items.length - 2 ) : null; } else { - lastNonGrouppedItem = this.items.last; + lastNonGroupedItem = this.items.last; } - this._grouppedItems.add( this.items.remove( lastNonGrouppedItem ), 0 ); + this._groupedItems.add( this.items.remove( lastNonGroupedItem ), 0 ); } /** @@ -330,9 +348,9 @@ export default class ToolbarView extends View { * @protected */ _ungroupFirstItem() { - this.items.add( this._grouppedItems.remove( 0 ), this.items.length - 1 ); + this.items.add( this._groupedItems.remove( 0 ), this.items.length - 1 ); - if ( !this._grouppedItems.length ) { + if ( !this._groupedItems.length ) { this.items.remove( this.wrappedItemsDropdown ); } } @@ -368,31 +386,58 @@ export default class ToolbarView extends View { * * @protected */ - _checkItemsWrappingAndUnwrapping() { + _groupOrUngroupWrappingItems() { + // Do not check when another check is going to avoid infinite loops. + // This method is called upon adding and removing #items and it adds and removes + // #items itself, so that would be a disaster. + if ( this._itemsWrappingLock ) { + return; + } + + // There's no way to check wrapping when there is no element (before #render()). + // Or when element has no parent because ClientRects won't work when #element not in DOM. if ( !this.element || !this.element.parentNode ) { return; } - let wereItemsGroupped; + this._itemsWrappingLock = true; + + let wereItemsGrouped; + // Group #items as long as any wraps to the next line. This will happen, for instance, + // when the toolbar is getting narrower and there's less and less space in it. while ( this._areItemsWrapping ) { this._groupLastItem(); - wereItemsGroupped = true; + + wereItemsGrouped = true; } - if ( !wereItemsGroupped && this.wrappedItemsDropdown && this._grouppedItems.length ) { - while ( !this._areItemsWrapping ) { + // If none were grouped now but there were some items already grouped before, + // then 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._hasWrappedItemsDropdown ) { + let areItemsWraping; + + // Ungroup items as long as none are wrapping to the next line... + while ( !( areItemsWraping = this._areItemsWrapping ) ) { this._ungroupFirstItem(); - if ( !this._grouppedItems.length ) { + // ...or there are none to ungroup left. + if ( !this._groupedItems.length ) { break; } } - if ( this._areItemsWrapping ) { + // If the ungrouping ended up with some item wrapping to the next line, + // 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. + if ( areItemsWraping ) { this._groupLastItem(); } } + + this._itemsWrappingLock = false; } } diff --git a/theme/components/toolbar/toolbar.css b/theme/components/toolbar/toolbar.css index 4d648fca..12c5530d 100644 --- a/theme/components/toolbar/toolbar.css +++ b/theme/components/toolbar/toolbar.css @@ -5,10 +5,6 @@ @import "../../mixins/_unselectable.css"; -:root { - --ck-toolbar-groupped-dropdown-arrow-size: calc(0.5 * var(--ck-icon-size)); -} - .ck.ck-toolbar { @mixin ck-unselectable; @@ -34,8 +30,8 @@ width: 100%; } -/* Pull the groupped items dropdown to the right */ -.ck.ck-toolbar__groupped-dropdown { +/* Pull the grouped items dropdown to the right */ +.ck.ck-toolbar__grouped-dropdown { margin-left: auto; /* From 6708e7113db9371ab86e59a0ec02d40a99b7be59 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 6 Aug 2019 17:27:46 +0200 Subject: [PATCH 05/39] Changed the template of the toolbar for better grouping feature. Code refac. --- src/toolbar/toolbarview.js | 252 +++++++++++------- theme/components/dropdown/toolbardropdown.css | 2 +- theme/components/toolbar/toolbar.css | 51 ++-- 3 files changed, 177 insertions(+), 128 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index c8fb2a14..2debc16e 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -10,6 +10,7 @@ /* globals console */ import View from '../view'; +import Template from '../template'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import FocusCycler from '../focuscycler'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; @@ -17,6 +18,7 @@ import ToolbarSeparatorView from './toolbarseparatorview'; import RectObserver from '../rectobserver'; import preventDefault from '../bindings/preventdefault.js'; import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import { createDropdown, addToolbarToDropdown } from '../dropdown/utils'; import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import verticalDotsIcon from '@ckeditor/ckeditor5-core/theme/icons/three-vertical-dots.svg'; @@ -63,17 +65,17 @@ export default class ToolbarView extends View { this.keystrokes = new KeystrokeHandler(); /** - * The dropdown that aggregates items that wrap to the next line. It is displayed + * The dropdown that aggregates items that overflow. It is displayed * at the end of the toolbar and offers a nested toolbar which displays items - * that would normally be wrapped to the next line. + * that would normally overflow. * * **Note:** It is created on demand when the space in the toolbar is scarce and only - * if {@link #enableWrappedItemsGrouping} has been called for this dropdown. + * if {@link #isGrouping} is `true`. * * @readonly - * @member {module:ui/dropdown/dropdownview~DropdownView} #wrappedItemsDropdown + * @member {module:ui/dropdown/dropdownview~DropdownView} #overflowedItemsDropdown */ - this.wrappedItemsDropdown = null; + this.overflowedItemsDropdown = null; /** * Controls the orientation of toolbar items. @@ -99,6 +101,12 @@ export default class ToolbarView extends View { */ this.set( 'isGrouping', false ); + this.on( 'change:isGrouping', ( evt, name, isGrouping ) => { + if ( isGrouping ) { + this._enableOverflowedItemsGroupingOnResize(); + } + } ); + /** * Helps cycling over focusable {@link #items} in the toolbar. * @@ -119,7 +127,42 @@ export default class ToolbarView extends View { } } ); - this._itemsWrappingLock = false; + /** + * TODO + * + * @readonly + * @protected + * @member {} + */ + this._overflowingItemsActionLock = false; + + /** + * TODO + * + * @readonly + * @protected + * @member {} + */ + this._paddingRight = null; + + /** + * TODO + * + * @readonly + * @protected + * @member {} + */ + this._rectObserver = null; + + /** + * TODO + * + * @readonly + * @protected + * @member {} + */ + this._components = this.createCollection(); + this._components.add( this._createItemsView() ); this.setTemplate( { tag: 'div', @@ -132,7 +175,7 @@ export default class ToolbarView extends View { ] }, - children: this.items, + children: this._components, on: { // https://github.com/ckeditor/ckeditor5-ui/issues/206 @@ -152,34 +195,28 @@ export default class ToolbarView extends View { this.focusTracker.add( item.element ); } - this.items.on( 'add', ( evt, item, index ) => { + this.items.on( 'add', ( evt, item ) => { this.focusTracker.add( item.element ); - if ( item !== this.wrappedItemsDropdown ) { - const wrappedItemsDropdownIndex = this.items.getIndex( this.wrappedItemsDropdown ); - - // Post–fixing if a new item was added after the wrapped items dropdown (pushed into #items). - // The dropdown must always be the last one no matter what. - if ( wrappedItemsDropdownIndex > -1 && index > wrappedItemsDropdownIndex ) { - this.items.add( this.items.remove( this.wrappedItemsDropdown ) ); - } - - this._groupOrUngroupWrappingItems( true ); + if ( this.isGrouping ) { + this._groupOrUngroupOverflowedItems(); } } ); this.items.on( 'remove', ( evt, item ) => { this.focusTracker.remove( item.element ); - if ( item !== this.wrappedItemsDropdown ) { - this._groupOrUngroupWrappingItems(); + if ( this.isGrouping ) { + this._groupOrUngroupOverflowedItems(); } } ); // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); - this._enableWrappedItemsGroupingOnResize(); + if ( this.isGrouping ) { + this._enableOverflowedItemsGroupingOnResize(); + } } /** @@ -188,8 +225,8 @@ export default class ToolbarView extends View { destroy() { // The dropdown may not be in #items at the moment of toolbar destruction // so let's make sure it's actually destroyed along with the toolbar. - if ( this.wrappedItemsDropdown ) { - this.wrappedItemsDropdown.destroy(); + if ( this.overflowedItemsDropdown ) { + this.overflowedItemsDropdown.destroy(); } return super.destroy(); @@ -218,7 +255,7 @@ export default class ToolbarView extends View { */ fillFromConfig( config, factory ) { // 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 wrap to the next line would be grouped + // If we filled it in in the natural order, items that overflow would be grouped // in a revere order. config.reverse().map( name => { if ( name == '|' ) { @@ -250,9 +287,52 @@ export default class ToolbarView extends View { } /** - * Enables the toolbar functionality that prevents its {@link #items} from wrapping to the next line + * TODO + */ + _createItemsView() { + const toolbarItemsView = new View( this.locale ); + + toolbarItemsView.template = new Template( { + tag: 'div', + attributes: { + class: [ + 'ck', + 'ck-toolbar__items' + ], + }, + children: this.items + } ); + + return toolbarItemsView; + } + + /** + * Creates the {@link #overflowedItemsDropdown} on demand. Used when the space in the toolbar + * is scarce and some items start overflow and need grouping. + * + * See {@link #isGrouping}. + * + * @protected + */ + _createOverflowedItemsDropdown() { + const t = this.t; + const locale = this.locale; + + this.overflowedItemsDropdown = createDropdown( locale ); + this.overflowedItemsDropdown.class = 'ck-toolbar__grouped-dropdown'; + addToolbarToDropdown( this.overflowedItemsDropdown, [] ); + + this.overflowedItemsDropdown.buttonView.set( { + label: t( 'Show more items' ), + tooltip: true, + icon: verticalDotsIcon + } ); + } + + /** + * Enables the toolbar functionality that prevents its {@link #items} from overflow * when the space becomes scarce. Instead, the toolbar items are grouped under the - * {@link #wrappedItemsDropdown dropdown} displayed at the end of the space, which offers its own + * {@link #overflowedItemsDropdown dropdown} displayed at the end of the space, which offers its own * nested toolbar. * * When called, the toolbar will automatically analyze the location of its children and "group" @@ -264,55 +344,39 @@ 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. */ - _enableWrappedItemsGroupingOnResize() { + _enableOverflowedItemsGroupingOnResize() { + if ( this._rectObserver ) { + return; + } + let oldRect; - this._groupOrUngroupWrappingItems(); + this._groupOrUngroupOverflowedItems(); // TODO: stopObserving on destroy(); - new RectObserver( this.element ).observe( newRect => { + this._rectObserver = new RectObserver( this.element ).observe( newRect => { if ( oldRect && oldRect.width !== newRect.width ) { - this._groupOrUngroupWrappingItems(); + this._groupOrUngroupOverflowedItems(); } oldRect = newRect; } ); } - get _groupedItems() { - return this.wrappedItemsDropdown.toolbarView.items; - } - /** - * Creates the {@link #wrappedItemsDropdown} on demand. Used when the space in the toolbar - * is scarce and some items start wrapping and need grouping. - * - * See {@link #_groupLastItem}. - * - * @protected + * TODO */ - _createWrappedItemsDropdown() { - const t = this.t; - const locale = this.locale; - - this.wrappedItemsDropdown = createDropdown( locale ); - this.wrappedItemsDropdown.class = 'ck-toolbar__grouped-dropdown'; - addToolbarToDropdown( this.wrappedItemsDropdown, [] ); - - this.wrappedItemsDropdown.buttonView.set( { - label: t( 'Show more items' ), - tooltip: true, - icon: verticalDotsIcon - } ); + get _groupedItems() { + return this.overflowedItemsDropdown.toolbarView.items; } - get _hasWrappedItemsDropdown() { - return this.wrappedItemsDropdown && this.items.has( this.wrappedItemsDropdown ); + get _hasOverflowedItemsDropdown() { + return this.overflowedItemsDropdown && this._components.has( this.overflowedItemsDropdown ); } /** * When called it will remove the last {@link #_lastNonGroupedItem regular item} from {@link #items} - * and move it to the {@link #wrappedItemsDropdown}. + * and move it to the {@link #overflowedItemsDropdown}. * * If the dropdown does not exist or does not * belong to {@link #items} it is created and located at the end of the collection. @@ -320,27 +384,19 @@ export default class ToolbarView extends View { * @protected */ _groupLastItem() { - if ( !this.wrappedItemsDropdown ) { - this._createWrappedItemsDropdown(); - } - - if ( !this._hasWrappedItemsDropdown ) { - this.items.add( this.wrappedItemsDropdown ); + if ( !this.overflowedItemsDropdown ) { + this._createOverflowedItemsDropdown(); } - let lastNonGroupedItem; - - if ( this._hasWrappedItemsDropdown ) { - lastNonGroupedItem = this.items.length > 1 ? this.items.get( this.items.length - 2 ) : null; - } else { - lastNonGroupedItem = this.items.last; + if ( !this._hasOverflowedItemsDropdown ) { + this._components.add( this.overflowedItemsDropdown ); } - this._groupedItems.add( this.items.remove( lastNonGroupedItem ), 0 ); + this._groupedItems.add( this.items.remove( this.items.last ), 0 ); } /** - * Moves the very first item from the toolbar belonging to {@link #wrappedItemsDropdown} back + * Moves the very first item from the toolbar belonging to {@link #overflowedItemsDropdown} back * to the {@link #items} collection. * * In some way, it's the opposite of {@link #_groupLastItem}. @@ -348,20 +404,20 @@ export default class ToolbarView extends View { * @protected */ _ungroupFirstItem() { - this.items.add( this._groupedItems.remove( 0 ), this.items.length - 1 ); + this.items.add( this._groupedItems.remove( this._groupedItems.first ) ); if ( !this._groupedItems.length ) { - this.items.remove( this.wrappedItemsDropdown ); + this._components.remove( this.overflowedItemsDropdown ); } } /** - * Returns `true` when any of toolbar {@link #items} wrapped visually to the next line. + * Returns `true` when any of toolbar {@link #items} overflows visually. * `false` otherwise. * * @protected */ - get _areItemsWrapping() { + get _areItemsOverflowing() { if ( !this.items.length ) { return false; } @@ -370,43 +426,44 @@ export default class ToolbarView extends View { return false; } - const firstItem = this.items.first; - const firstItemRect = new Rect( firstItem.element ); - const lastItemRect = new Rect( this.items.last.element ); + if ( !this._paddingRight ) { + this._paddingRight = Number.parseFloat( + global.window.getComputedStyle( this.element ).paddingRight ); + } - return firstItemRect.bottom < lastItemRect.top; + return new Rect( this.element.lastChild ).right > new Rect( this.element ).right - this._paddingRight; } /** - * When called it will check if any of the {@link #items} wraps to the next line and if so, it will - * move it to the {@link #wrappedItemsDropdown}. + * When called it will check if any of the {@link #items} overflow and if so, it will + * move it to the {@link #overflowedItemsDropdown}. * * At the same time, it will also check if there is enough space in the toolbar for the first of the - * "grouped" items in the {@link #wrappedItemsDropdown} to be returned back. + * "grouped" items in the {@link #overflowedItemsDropdown} to be returned back. * * @protected */ - _groupOrUngroupWrappingItems() { + _groupOrUngroupOverflowedItems() { // Do not check when another check is going to avoid infinite loops. // This method is called upon adding and removing #items and it adds and removes // #items itself, so that would be a disaster. - if ( this._itemsWrappingLock ) { + if ( this._overflowingItemsActionLock ) { return; } - // There's no way to check wrapping when there is no element (before #render()). + // There's no way to check overflow when there is no element (before #render()). // Or when element has no parent because ClientRects won't work when #element not in DOM. if ( !this.element || !this.element.parentNode ) { return; } - this._itemsWrappingLock = true; + this._overflowingItemsActionLock = true; let wereItemsGrouped; - // Group #items as long as any wraps to the next line. This will happen, for instance, + // Group #items as long as any overflows. This will happen, for instance, // when the toolbar is getting narrower and there's less and less space in it. - while ( this._areItemsWrapping ) { + while ( this._areItemsOverflowing ) { this._groupLastItem(); wereItemsGrouped = true; @@ -415,29 +472,22 @@ export default class ToolbarView extends View { // If none were grouped now but there were some items already grouped before, // then 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._hasWrappedItemsDropdown ) { - let areItemsWraping; - - // Ungroup items as long as none are wrapping to the next line... - while ( !( areItemsWraping = this._areItemsWrapping ) ) { + if ( !wereItemsGrouped && this._hasOverflowedItemsDropdown ) { + // Ungroup items as long as none are overflowing or there are none to ungroup left. + while ( this._groupedItems.length && !this._areItemsOverflowing ) { this._ungroupFirstItem(); - - // ...or there are none to ungroup left. - if ( !this._groupedItems.length ) { - break; - } } - // If the ungrouping ended up with some item wrapping to the next line, + // If the ungrouping ended up with some item overflowing, // 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 + // an item will overflow or not until we ungroup it (that's a DOM/CSS thing) so this // clean–up is vital. - if ( areItemsWraping ) { + if ( this._areItemsOverflowing ) { this._groupLastItem(); } } - this._itemsWrappingLock = false; + this._overflowingItemsActionLock = false; } } diff --git a/theme/components/dropdown/toolbardropdown.css b/theme/components/dropdown/toolbardropdown.css index e23e278c..bd1f5504 100644 --- a/theme/components/dropdown/toolbardropdown.css +++ b/theme/components/dropdown/toolbardropdown.css @@ -4,7 +4,7 @@ */ .ck.ck-toolbar-dropdown { - & .ck-toolbar { + & .ck.ck-toolbar .ck.ck-toolbar__items { flex-wrap: nowrap; } diff --git a/theme/components/toolbar/toolbar.css b/theme/components/toolbar/toolbar.css index 12c5530d..dbd6643e 100644 --- a/theme/components/toolbar/toolbar.css +++ b/theme/components/toolbar/toolbar.css @@ -9,7 +9,7 @@ @mixin ck-unselectable; display: flex; - flex-flow: row wrap; + flex-flow: row nowrap; align-items: center; &.ck-toolbar_vertical { @@ -19,34 +19,33 @@ &.ck-toolbar_floating { flex-wrap: nowrap; } -} - -.ck.ck-toolbar__separator { - display: inline-block; -} - -.ck.ck-toolbar__newline { - display: block; - width: 100%; -} - -/* Pull the grouped items dropdown to the right */ -.ck.ck-toolbar__grouped-dropdown { - margin-left: auto; - /* - * Dropdown button has asymmetric padding to fit the arrow. - * This button has no arrow so let's revert that padding back to normal. - */ - & > .ck.ck-button.ck-dropdown__button { - padding-left: var(--ck-spacing-tiny); - } + & > .ck-toolbar__items { + display: flex; + flex-flow: row nowrap; + align-items: center; + flex-grow: 1; - & > .ck-dropdown__button .ck-dropdown__arrow { - display: none; + & > .ck.ck-toolbar__separator { + display: inline-block; + } } - & > .ck.ck-dropdown__panel > .ck-toolbar { - flex-wrap: nowrap; + & > .ck.ck-toolbar__grouped-dropdown { + /* + * Dropdown button has asymmetric padding to fit the arrow. + * This button has no arrow so let's revert that padding back to normal. + */ + & > .ck.ck-button.ck-dropdown__button { + padding-left: var(--ck-spacing-tiny); + } + + & > .ck-dropdown__button .ck-dropdown__arrow { + display: none; + } + + & > .ck.ck-dropdown__panel > .ck-toolbar { + flex-wrap: nowrap; + } } } From 9ac351f36be8657517dd45747ad50b114588f71b Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 7 Aug 2019 13:19:20 +0200 Subject: [PATCH 06/39] Further improvements and code refactoring. --- src/rectobserver.js | 67 ------- src/toolbar/toolbarview.js | 281 ++++++++++++++------------- theme/components/toolbar/toolbar.css | 35 ++-- 3 files changed, 171 insertions(+), 212 deletions(-) delete mode 100644 src/rectobserver.js diff --git a/src/rectobserver.js b/src/rectobserver.js deleted file mode 100644 index 956acaaa..00000000 --- a/src/rectobserver.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module ui/RectObserver - */ - -/* globals setTimeout, clearTimeout, ResizeObserver */ - -import mix from '@ckeditor/ckeditor5-utils/src/mix'; -import global from '@ckeditor/ckeditor5-utils/src/dom/global'; -import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; -import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin'; - -export default class RectObserver { - constructor( element ) { - this.element = element; - } - - observe( callback ) { - if ( typeof ResizeObserver === 'function' ) { - this._observer = new ResizeObserver( entries => { - callback( new Rect( entries[ 0 ].contentRect ) ); - } ).observe( this.element ); - } else { - let previousRect; - - const hasRectChanged = () => { - const currentRect = new Rect( this.element ); - const hasChanged = previousRect && !previousRect.isEqual( currentRect ); - - previousRect = currentRect; - - return hasChanged; - }; - - const periodicCheck = () => { - if ( hasRectChanged() ) { - callback( previousRect ); - } - - this._checkTimeout = setTimeout( periodicCheck, 500 ); - }; - - this.listenTo( global.window, 'resize', () => { - if ( hasRectChanged() ) { - callback( previousRect ); - } - } ); - - periodicCheck(); - } - } - - stopObserving() { - if ( this._observer ) { - this._observer.disconnect(); - } else { - this.stopListening(); - clearTimeout( this._checkTimeout ); - } - } -} - -mix( RectObserver, DomEmitterMixin ); diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 2debc16e..2d8fdbcf 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -15,7 +15,7 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import FocusCycler from '../focuscycler'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; import ToolbarSeparatorView from './toolbarseparatorview'; -import RectObserver from '../rectobserver'; +import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver'; import preventDefault from '../bindings/preventdefault.js'; import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; @@ -70,7 +70,7 @@ export default class ToolbarView extends View { * that would normally overflow. * * **Note:** It is created on demand when the space in the toolbar is scarce and only - * if {@link #isGrouping} is `true`. + * if {@link #shouldGroupWhenFull} is `true`. * * @readonly * @member {module:ui/dropdown/dropdownview~DropdownView} #overflowedItemsDropdown @@ -97,12 +97,13 @@ export default class ToolbarView extends View { * TODO * * @observable - * @member {Boolean} #isGrouping + * @member {Boolean} #shouldGroupWhenFull */ - this.set( 'isGrouping', false ); + this.set( 'shouldGroupWhenFull', false ); - this.on( 'change:isGrouping', ( evt, name, isGrouping ) => { - if ( isGrouping ) { + // Grouping can be enabled before or after render. + this.on( 'change:shouldGroupWhenFull', () => { + if ( this.shouldGroupWhenFull ) { this._enableOverflowedItemsGroupingOnResize(); } } ); @@ -132,16 +133,16 @@ export default class ToolbarView extends View { * * @readonly * @protected - * @member {} + * @member {Boolean} */ - this._overflowingItemsActionLock = false; + this._updateLock = false; /** * TODO * * @readonly * @protected - * @member {} + * @member {Number} */ this._paddingRight = null; @@ -152,14 +153,14 @@ export default class ToolbarView extends View { * @protected * @member {} */ - this._rectObserver = null; + this._resizeObserver = null; /** * TODO * * @readonly * @protected - * @member {} + * @member {module:ui/viewcollection~ViewCollection} */ this._components = this.createCollection(); this._components.add( this._createItemsView() ); @@ -171,6 +172,7 @@ export default class ToolbarView extends View { 'ck', 'ck-toolbar', bind.if( 'isVertical', 'ck-toolbar_vertical' ), + bind.if( 'shouldGroupWhenFull', 'ck-toolbar_grouping' ), bind.to( 'class' ) ] }, @@ -197,26 +199,16 @@ export default class ToolbarView extends View { this.items.on( 'add', ( evt, item ) => { this.focusTracker.add( item.element ); - - if ( this.isGrouping ) { - this._groupOrUngroupOverflowedItems(); - } + this.update(); } ); this.items.on( 'remove', ( evt, item ) => { this.focusTracker.remove( item.element ); - - if ( this.isGrouping ) { - this._groupOrUngroupOverflowedItems(); - } + this.update(); } ); // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); - - if ( this.isGrouping ) { - this._enableOverflowedItemsGroupingOnResize(); - } } /** @@ -229,6 +221,10 @@ export default class ToolbarView extends View { this.overflowedItemsDropdown.destroy(); } + if ( this._resizeObserver ) { + this._resizeObserver.disconnect(); + } + return super.destroy(); } @@ -286,8 +282,116 @@ export default class ToolbarView extends View { } ); } + /** + * When called, if {@link #shouldGroupWhenFull} is `true`, it will check if any of the {@link #items} overflow + * and if so, it will move it to the {@link #overflowedItemsDropdown}. + * + * At the same time, it will also check if there is enough space in the toolbar for the first of the + * "grouped" items in the {@link #overflowedItemsDropdown} to be returned back. + */ + update() { + if ( !this.shouldGroupWhenFull ) { + return; + } + + // Do not check when another check is going to avoid infinite loops. + // This method is called upon adding and removing #items and it adds and removes + // #items itself, so that would be a disaster. + if ( this._updateLock ) { + return; + } + + // There's no way to check overflow when there is no element (before #render()). + // Or when element has no parent because ClientRects won't work when #element not in DOM. + if ( !this.element || !this.element.parentNode ) { + return; + } + + this._updateLock = true; + + let wereItemsGrouped; + + // Group #items as long as any overflows. This will happen, for instance, + // when the toolbar is getting narrower and there's less and less space in it. + while ( this._areItemsOverflowing ) { + this._groupLastItem(); + + wereItemsGrouped = true; + } + + // If none were grouped now but there were some items already grouped before, + // then 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._hasOverflowedItemsDropdown ) { + // Ungroup items as long as none are overflowing or there are none to ungroup left. + while ( this._overflowedItems.length && !this._areItemsOverflowing ) { + this._ungroupFirstItem(); + } + + // If the ungrouping ended up with some item overflowing, + // put it back to the group toolbar (undo the last ungroup). We don't know whether + // an item will overflow or not until we ungroup it (that's a DOM/CSS thing) so this + // clean–up is vital. + if ( this._areItemsOverflowing ) { + this._groupLastItem(); + } + } + + this._updateLock = false; + } + + /** + * TODO + * + * @protected + * @type {module:ui/viewcollection~ViewCollection} + */ + get _overflowedItems() { + return this.overflowedItemsDropdown.toolbarView.items; + } + + /** + * TODO + * + * @protected + * @type {Boolean} + */ + get _hasOverflowedItemsDropdown() { + return this.overflowedItemsDropdown && this._components.has( this.overflowedItemsDropdown ); + } + + /** + * Returns `true` when any of toolbar {@link #items} overflows visually. + * `false` otherwise. + * + * @protected + * @type {Boolean} + */ + get _areItemsOverflowing() { + // An empty toolbar cannot overflow. + if ( !this.items.length ) { + return false; + } + + if ( !this._paddingRight ) { + // parseInt() is essential because of quirky floating point numbers logic and DOM. + // If the padding turned out too big because of that, the groupped items dropdown would + // always look (from the Rect perspective) like it overflows (while it's not). + this._paddingRight = Number.parseInt( + global.window.getComputedStyle( this.element ).paddingRight ); + } + + const lastChildRect = new Rect( this.element.lastChild ); + const toolbarRect = new Rect( this.element ); + + return lastChildRect.right > toolbarRect.right - this._paddingRight; + } + /** * TODO + * + * @protected + * @returns {module:ui/view~View} */ _createItemsView() { const toolbarItemsView = new View( this.locale ); @@ -310,23 +414,26 @@ export default class ToolbarView extends View { * Creates the {@link #overflowedItemsDropdown} on demand. Used when the space in the toolbar * is scarce and some items start overflow and need grouping. * - * See {@link #isGrouping}. + * See {@link #shouldGroupWhenFull}. * * @protected + * @returns {module:ui/dropdown/dropdownview~DropdownView} */ _createOverflowedItemsDropdown() { const t = this.t; const locale = this.locale; + const overflowedItemsDropdown = createDropdown( locale ); - this.overflowedItemsDropdown = createDropdown( locale ); - this.overflowedItemsDropdown.class = 'ck-toolbar__grouped-dropdown'; - addToolbarToDropdown( this.overflowedItemsDropdown, [] ); + overflowedItemsDropdown.class = 'ck-toolbar__grouped-dropdown'; + addToolbarToDropdown( overflowedItemsDropdown, [] ); - this.overflowedItemsDropdown.buttonView.set( { + overflowedItemsDropdown.buttonView.set( { label: t( 'Show more items' ), tooltip: true, icon: verticalDotsIcon } ); + + return overflowedItemsDropdown; } /** @@ -345,54 +452,43 @@ export default class ToolbarView extends View { * the geometry of the toolbar items — they depend on the toolbar to be visible in DOM. */ _enableOverflowedItemsGroupingOnResize() { - if ( this._rectObserver ) { + if ( this._resizeObserver ) { return; } let oldRect; - this._groupOrUngroupOverflowedItems(); - // TODO: stopObserving on destroy(); - this._rectObserver = new RectObserver( this.element ).observe( newRect => { - if ( oldRect && oldRect.width !== newRect.width ) { - this._groupOrUngroupOverflowedItems(); + this._resizeObserver = new ResizeObserver( ( [ entry ] ) => { + if ( !oldRect || oldRect.width !== entry.contentRect.width ) { + this.update(); } - oldRect = newRect; - } ); - } + oldRect = entry.contentRect.width; + } ).observe( this.element ); - /** - * TODO - */ - get _groupedItems() { - return this.overflowedItemsDropdown.toolbarView.items; - } - - get _hasOverflowedItemsDropdown() { - return this.overflowedItemsDropdown && this._components.has( this.overflowedItemsDropdown ); + this.update(); } /** * When called it will remove the last {@link #_lastNonGroupedItem regular item} from {@link #items} - * and move it to the {@link #overflowedItemsDropdown}. + * and move it to the {@link #overflowedItemsDropdown}. The opposite of {@link _ungroupFirstItem}. * - * If the dropdown does not exist or does not - * belong to {@link #items} it is created and located at the end of the collection. + * If the dropdown does not exist or does not belong to {@link #items} it is created and located at + * the end of the collection. * * @protected */ _groupLastItem() { if ( !this.overflowedItemsDropdown ) { - this._createOverflowedItemsDropdown(); + this.overflowedItemsDropdown = this._createOverflowedItemsDropdown(); } if ( !this._hasOverflowedItemsDropdown ) { this._components.add( this.overflowedItemsDropdown ); } - this._groupedItems.add( this.items.remove( this.items.last ), 0 ); + this._overflowedItems.add( this.items.remove( this.items.last ), 0 ); } /** @@ -404,90 +500,11 @@ export default class ToolbarView extends View { * @protected */ _ungroupFirstItem() { - this.items.add( this._groupedItems.remove( this._groupedItems.first ) ); + this.items.add( this._overflowedItems.remove( this._overflowedItems.first ) ); - if ( !this._groupedItems.length ) { + if ( !this._overflowedItems.length ) { this._components.remove( this.overflowedItemsDropdown ); } } - - /** - * Returns `true` when any of toolbar {@link #items} overflows visually. - * `false` otherwise. - * - * @protected - */ - get _areItemsOverflowing() { - if ( !this.items.length ) { - return false; - } - - if ( this.items.length === 1 ) { - return false; - } - - if ( !this._paddingRight ) { - this._paddingRight = Number.parseFloat( - global.window.getComputedStyle( this.element ).paddingRight ); - } - - return new Rect( this.element.lastChild ).right > new Rect( this.element ).right - this._paddingRight; - } - - /** - * When called it will check if any of the {@link #items} overflow and if so, it will - * move it to the {@link #overflowedItemsDropdown}. - * - * At the same time, it will also check if there is enough space in the toolbar for the first of the - * "grouped" items in the {@link #overflowedItemsDropdown} to be returned back. - * - * @protected - */ - _groupOrUngroupOverflowedItems() { - // Do not check when another check is going to avoid infinite loops. - // This method is called upon adding and removing #items and it adds and removes - // #items itself, so that would be a disaster. - if ( this._overflowingItemsActionLock ) { - return; - } - - // There's no way to check overflow when there is no element (before #render()). - // Or when element has no parent because ClientRects won't work when #element not in DOM. - if ( !this.element || !this.element.parentNode ) { - return; - } - - this._overflowingItemsActionLock = true; - - let wereItemsGrouped; - - // Group #items as long as any overflows. This will happen, for instance, - // when the toolbar is getting narrower and there's less and less space in it. - while ( this._areItemsOverflowing ) { - this._groupLastItem(); - - wereItemsGrouped = true; - } - - // If none were grouped now but there were some items already grouped before, - // then 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._hasOverflowedItemsDropdown ) { - // 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 overflowing, - // put it back to the group toolbar (undo the last ungroup). We don't know whether - // an item will overflow or not until we ungroup it (that's a DOM/CSS thing) so this - // clean–up is vital. - if ( this._areItemsOverflowing ) { - this._groupLastItem(); - } - } - - this._overflowingItemsActionLock = false; - } } diff --git a/theme/components/toolbar/toolbar.css b/theme/components/toolbar/toolbar.css index dbd6643e..c15a3e7d 100644 --- a/theme/components/toolbar/toolbar.css +++ b/theme/components/toolbar/toolbar.css @@ -12,25 +12,38 @@ flex-flow: row nowrap; align-items: center; - &.ck-toolbar_vertical { - flex-direction: column; - } - - &.ck-toolbar_floating { - flex-wrap: nowrap; - } - & > .ck-toolbar__items { display: flex; - flex-flow: row nowrap; + flex-flow: row wrap; align-items: center; flex-grow: 1; & > .ck.ck-toolbar__separator { display: inline-block; + + /* + * A leading or trailing separator makes no sense (separates nothing from one side). + * Better hide it for better look. + */ + &:first-child, + &:last-child { + display: none; + } } } + &.ck-toolbar_grouping > .ck-toolbar__items { + flex-wrap: nowrap; + } + + &.ck-toolbar_vertical > .ck-toolbar__items { + flex-direction: column; + } + + &.ck-toolbar_floating > .ck-toolbar__items { + flex-wrap: nowrap; + } + & > .ck.ck-toolbar__grouped-dropdown { /* * Dropdown button has asymmetric padding to fit the arrow. @@ -43,9 +56,5 @@ & > .ck-dropdown__button .ck-dropdown__arrow { display: none; } - - & > .ck.ck-dropdown__panel > .ck-toolbar { - flex-wrap: nowrap; - } } } From 68cc9c52519492659297567a89d6068f89edbcb4 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 7 Aug 2019 15:15:59 +0200 Subject: [PATCH 07/39] Fixed the broken keyboard navigation in the toolbar. --- src/toolbar/toolbarview.js | 182 +++++++++++++++++++++------ theme/components/toolbar/toolbar.css | 23 ++-- 2 files changed, 156 insertions(+), 49 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 2d8fdbcf..be480c37 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -10,7 +10,6 @@ /* globals console */ import View from '../view'; -import Template from '../template'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import FocusCycler from '../focuscycler'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; @@ -109,24 +108,12 @@ export default class ToolbarView extends View { } ); /** - * Helps cycling over focusable {@link #items} in the toolbar. + * TODO * * @readonly - * @protected - * @member {module:ui/focuscycler~FocusCycler} + * @member {itemsView} */ - this._focusCycler = new FocusCycler( { - focusables: this.items, - focusTracker: this.focusTracker, - keystrokeHandler: this.keystrokes, - actions: { - // Navigate toolbar items backwards using the arrow[left,up] keys. - focusPrevious: [ 'arrowleft', 'arrowup' ], - - // Navigate toolbar items forwards using the arrow[right,down] keys. - focusNext: [ 'arrowright', 'arrowdown' ] - } - } ); + this.itemsView = this._createItemsView(); /** * TODO @@ -163,7 +150,64 @@ export default class ToolbarView extends View { * @member {module:ui/viewcollection~ViewCollection} */ this._components = this.createCollection(); - this._components.add( this._createItemsView() ); + this._components.add( this.itemsView ); + + /** + * Helps cycling over focusable {@link #items} in the toolbar. + * + * @readonly + * @protected + * @member {module:ui/focuscycler~FocusCycler} + */ + this._itemsFocusCycler = new FocusCycler( { + focusables: this.itemsView.items, + focusTracker: this.itemsView.focusTracker, + } ); + + this._componentsFocusCycler = new FocusCycler( { + focusables: this._components, + focusTracker: this.focusTracker, + } ); + + this.keystrokes.set( 'arrowright', ( keyEvtData, cancel ) => { + if ( this.itemsView.focusTracker.isFocused ) { + if ( this._itemsFocusCycler.next === this._itemsFocusCycler.first ) { + this._componentsFocusCycler.focusNext(); + } else { + this._itemsFocusCycler.focusNext(); + } + + cancel(); + } else { + this._componentsFocusCycler.focusNext(); + + cancel(); + } + } ); + + this.keystrokes.set( 'arrowleft', ( keyEvtData, cancel ) => { + if ( this.itemsView.focusTracker.isFocused ) { + if ( this._itemsFocusCycler.previous === this._itemsFocusCycler.last ) { + if ( this._hasOverflowedItemsDropdown ) { + this._componentsFocusCycler.focusLast(); + } else { + this._itemsFocusCycler.focusPrevious(); + } + } else { + this._itemsFocusCycler.focusPrevious(); + } + + cancel(); + } else { + if ( this._componentsFocusCycler.previous === this.itemsView ) { + this._itemsFocusCycler.focusLast(); + } else { + this._componentsFocusCycler.focusPrevious(); + } + + cancel(); + } + } ); this.setTemplate( { tag: 'div', @@ -192,18 +236,24 @@ export default class ToolbarView extends View { render() { super.render(); - // Items added before rendering should be known to the #focusTracker. - for ( const item of this.items ) { - this.focusTracker.add( item.element ); + // Components added before rendering should be known to the #focusTracker. + for ( const component of this._components ) { + this.focusTracker.add( component.element ); } - this.items.on( 'add', ( evt, item ) => { - 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.update(); } ); - this.items.on( 'remove', ( evt, item ) => { - this.focusTracker.remove( item.element ); + this.items.on( 'remove', () => { this.update(); } ); @@ -232,14 +282,14 @@ export default class ToolbarView extends View { * Focuses the first focusable in {@link #items}. */ focus() { - this._focusCycler.focusFirst(); + this._componentsFocusCycler.focusFirst(); } /** * Focuses the last focusable in {@link #items}. */ focusLast() { - this._focusCycler.focusLast(); + this._componentsFocusCycler.focusLast(); } /** @@ -394,20 +444,12 @@ export default class ToolbarView extends View { * @returns {module:ui/view~View} */ _createItemsView() { - const toolbarItemsView = new View( this.locale ); + const itemsView = new ToolbarItemsView( this.locale ); - toolbarItemsView.template = new Template( { - tag: 'div', - attributes: { - class: [ - 'ck', - 'ck-toolbar__items' - ], - }, - children: this.items - } ); + // 1:1 pass–through binding. + itemsView.items.bindTo( this.items ).using( item => item ); - return toolbarItemsView; + return itemsView; } /** @@ -485,6 +527,7 @@ export default class ToolbarView extends View { } if ( !this._hasOverflowedItemsDropdown ) { + this._components.add( new ToolbarSeparatorView() ); this._components.add( this.overflowedItemsDropdown ); } @@ -504,7 +547,70 @@ export default class ToolbarView extends View { if ( !this._overflowedItems.length ) { this._components.remove( this.overflowedItemsDropdown ); + this._components.remove( this._components.last ); } } } +class ToolbarItemsView extends View { + constructor( locale ) { + super( locale ); + + this.items = this.createCollection(); + + 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 + } ); + } + + render() { + super.render(); + + for ( const item of this.items ) { + this.focusTracker.add( item.element ); + } + + 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(); + } + + /** + * Focuses the last focusable in {@link #items}. + */ + focusLast() { + this._focusCycler.focusLast(); + } +} diff --git a/theme/components/toolbar/toolbar.css b/theme/components/toolbar/toolbar.css index c15a3e7d..39838b99 100644 --- a/theme/components/toolbar/toolbar.css +++ b/theme/components/toolbar/toolbar.css @@ -18,17 +18,18 @@ align-items: center; flex-grow: 1; - & > .ck.ck-toolbar__separator { - display: inline-block; - - /* - * A leading or trailing separator makes no sense (separates nothing from one side). - * Better hide it for better look. - */ - &:first-child, - &:last-child { - display: none; - } + } + + & .ck.ck-toolbar__separator { + display: inline-block; + + /* + * A leading or trailing separator makes no sense (separates nothing from one side). + * Better hide it for better look. + */ + &:first-child, + &:last-child { + display: none; } } From 2125464f26a94d7086c666173140c7a9807c4369 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Thu, 8 Aug 2019 09:48:54 +0200 Subject: [PATCH 08/39] Re-enabled toolbar keyboard navigation on arrowup and down. --- src/toolbar/toolbarview.js | 117 ++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 40 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index be480c37..37b58e90 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -143,7 +143,13 @@ export default class ToolbarView extends View { this._resizeObserver = null; /** - * TODO + * ┌────────────────────────────────── #_components ──────────────────────────────────────┐ + * | | + * | ┌──── #itemsView───────┐ ┌──────────────────────┐ ┌──────────────────────────┐ | + * | | ... | | ToolbarSeparatorView | | #overflowedItemsDropdown | | + * | └─────────────────────-┘ └──────────────────────┘ └──────────────────────────┘ | + * | \---------- only when #shouldGroupWhenFull ---------/ | + * +──────────────────────────────────────────────────────────────────────────────────────┘ * * @readonly * @protected @@ -169,45 +175,10 @@ export default class ToolbarView extends View { focusTracker: this.focusTracker, } ); - this.keystrokes.set( 'arrowright', ( keyEvtData, cancel ) => { - if ( this.itemsView.focusTracker.isFocused ) { - if ( this._itemsFocusCycler.next === this._itemsFocusCycler.first ) { - this._componentsFocusCycler.focusNext(); - } else { - this._itemsFocusCycler.focusNext(); - } - - cancel(); - } else { - this._componentsFocusCycler.focusNext(); - - cancel(); - } - } ); - - this.keystrokes.set( 'arrowleft', ( keyEvtData, cancel ) => { - if ( this.itemsView.focusTracker.isFocused ) { - if ( this._itemsFocusCycler.previous === this._itemsFocusCycler.last ) { - if ( this._hasOverflowedItemsDropdown ) { - this._componentsFocusCycler.focusLast(); - } else { - this._itemsFocusCycler.focusPrevious(); - } - } else { - this._itemsFocusCycler.focusPrevious(); - } - - cancel(); - } else { - if ( this._componentsFocusCycler.previous === this.itemsView ) { - this._itemsFocusCycler.focusLast(); - } else { - this._componentsFocusCycler.focusPrevious(); - } - - cancel(); - } - } ); + this.keystrokes.set( 'arrowleft', this._onKeyboardPrevious.bind( this ) ); + this.keystrokes.set( 'arrowup', this._onKeyboardPrevious.bind( this ) ); + this.keystrokes.set( 'arrowright', this._onKeyboardNext.bind( this ) ); + this.keystrokes.set( 'arrowdown', this._onKeyboardNext.bind( this ) ); this.setTemplate( { tag: 'div', @@ -478,6 +449,72 @@ export default class ToolbarView extends View { return overflowedItemsDropdown; } + /** + * ┌────────────────────────────── #_components ────────────────────────────────────────┐ + * | | + * | /────▶────\ /───────▶───────▶───────\ /────▶─────\ | + * | | ▼ ▲ ▼ ▲ | | + * | | ┌─|──── #items ──────|─┐ ┌───────|──────────|───────┐ | | + * | ▲ | \───▶──────────▶───/ | | #overflowedItemsDropdown | ▼ | + * | | └─────────────────────-┘ └──────────────────────────┘ | | + * | | | | + * | └─────◀───────────◀────────────◀──────────────◀──────────────◀─────────────/ | + * | | + * +────────────────────────────────────────────────────────────────────────────────────┘ + */ + _onKeyboardNext( keyEvtData, cancel ) { + if ( this.itemsView.focusTracker.isFocused ) { + if ( this._itemsFocusCycler.next === this._itemsFocusCycler.first ) { + this._componentsFocusCycler.focusNext(); + } else { + this._itemsFocusCycler.focusNext(); + } + + cancel(); + } else { + this._componentsFocusCycler.focusNext(); + + cancel(); + } + } + + /** + * ┌────────────────────────────── #_components ────────────────────────────────────────┐ + * | | + * | /────◀────\ /───────◀───────◀───────\ /────◀─────\ | + * | | ▲ ▼ ▲ ▼ | | + * | | ┌─|──── #items ──────|─┐ ┌───────|──────────|───────┐ | | + * | ▼ | \───◀──────────◀───/ | | #overflowedItemsDropdown | ▲ | + * | | └─────────────────────-┘ └──────────────────────────┘ | | + * | | | | + * | └─────▶───────────▶────────────▶──────────────▶──────────────▶─────────────/ | + * | | + * +────────────────────────────────────────────────────────────────────────────────────┘ + */ + _onKeyboardPrevious( keyEvtData, cancel ) { + if ( this.itemsView.focusTracker.isFocused ) { + if ( this._itemsFocusCycler.previous === this._itemsFocusCycler.last ) { + if ( this._hasOverflowedItemsDropdown ) { + this._componentsFocusCycler.focusLast(); + } else { + this._itemsFocusCycler.focusPrevious(); + } + } else { + this._itemsFocusCycler.focusPrevious(); + } + + cancel(); + } else { + if ( this._componentsFocusCycler.previous === this.itemsView ) { + this._itemsFocusCycler.focusLast(); + } else { + this._componentsFocusCycler.focusPrevious(); + } + + cancel(); + } + } + /** * Enables the toolbar functionality that prevents its {@link #items} from overflow * when the space becomes scarce. Instead, the toolbar items are grouped under the From 823c5504361dd241f7432830ffc43540e087d760 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 9 Sep 2019 14:15:34 +0200 Subject: [PATCH 09/39] Added a RTL UI support in the automatic toolbar grouping feature. --- src/toolbar/toolbarview.js | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index de31de97..11e4a954 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -144,7 +144,7 @@ export default class ToolbarView extends View { * @protected * @member {Number} */ - this._paddingRight = null; + this._horizontalPadding = null; /** * TODO @@ -409,18 +409,25 @@ export default class ToolbarView extends View { return false; } - if ( !this._paddingRight ) { + const uiLanguageDirection = this.locale.uiLanguageDirection; + const lastChildRect = new Rect( this.element.lastChild ); + const toolbarRect = new Rect( this.element ); + + if ( !this._horizontalPadding ) { + 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 groupped items dropdown would + // 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._paddingRight = Number.parseInt( - global.window.getComputedStyle( this.element ).paddingRight ); + this._horizontalPadding = Number.parseInt( computedStyle[ paddingProperty ] ); } - const lastChildRect = new Rect( this.element.lastChild ); - const toolbarRect = new Rect( this.element ); - - return lastChildRect.right > toolbarRect.right - this._paddingRight; + if ( uiLanguageDirection === 'ltr' ) { + return lastChildRect.right > toolbarRect.right - this._horizontalPadding; + } else { + return lastChildRect.left < toolbarRect.left + this._horizontalPadding; + } } /** From f6eed574707e93110c5099db7bfc0cda3764df1f Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 9 Sep 2019 15:04:56 +0200 Subject: [PATCH 10/39] Aligned API to changes in the "resizeobserver" module. --- src/toolbar/toolbarview.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 11e4a954..8fd9ada4 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -14,7 +14,7 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import FocusCycler from '../focuscycler'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; import ToolbarSeparatorView from './toolbarseparatorview'; -import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver'; +import getResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver'; import preventDefault from '../bindings/preventdefault.js'; import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; @@ -560,7 +560,7 @@ export default class ToolbarView extends View { let oldRect; // TODO: stopObserving on destroy(); - this._resizeObserver = new ResizeObserver( ( [ entry ] ) => { + this._resizeObserver = getResizeObserver( ( [ entry ] ) => { if ( !oldRect || oldRect.width !== entry.contentRect.width ) { this.update(); } From 86fb29ec0ebafed0e4d0cbf8b3e18813e3c8b749 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 11 Sep 2019 15:47:36 +0200 Subject: [PATCH 11/39] Code refactoring and docs in the ToolbarView class. --- src/toolbar/toolbarview.js | 324 +++++++++++++++++++++++-------------- 1 file changed, 205 insertions(+), 119 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 8fd9ada4..90e88363 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -14,7 +14,7 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import FocusCycler from '../focuscycler'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; import ToolbarSeparatorView from './toolbarseparatorview'; -import getResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver'; +import getResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/getresizeobserver'; import preventDefault from '../bindings/preventdefault.js'; import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; @@ -53,7 +53,11 @@ export default class ToolbarView extends View { this.set( 'ariaLabel', t( 'Editor toolbar' ) ); /** - * Collection of the toolbar items (like buttons). + * 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} @@ -61,7 +65,18 @@ export default class ToolbarView extends View { this.items = this.createCollection(); /** - * Tracks information about DOM focus in the list. + * 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. * * @readonly * @member {module:utils/focustracker~FocusTracker} @@ -69,7 +84,8 @@ export default class ToolbarView extends View { this.focusTracker = new FocusTracker(); /** - * Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + * Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler} + * to handle keyboard navigation in the toolbar. * * @readonly * @member {module:utils/keystrokehandler~KeystrokeHandler} @@ -77,17 +93,30 @@ export default class ToolbarView extends View { this.keystrokes = new KeystrokeHandler(); /** - * The dropdown that aggregates items that overflow. It is displayed - * at the end of the toolbar and offers a nested toolbar which displays items - * that would normally overflow. + * 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:** It is created on demand when the space in the toolbar is scarce and only - * if {@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 - * @member {module:ui/dropdown/dropdownview~DropdownView} #overflowedItemsDropdown + * @member {module:ui/dropdown/dropdownview~DropdownView} #groupedItemsDropdown */ - this.overflowedItemsDropdown = null; + 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. @@ -106,7 +135,13 @@ export default class ToolbarView extends View { this.set( 'class' ); /** - * TODO + * 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 @@ -121,24 +156,26 @@ export default class ToolbarView extends View { } ); /** - * TODO + * 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. * - * @readonly - * @member {itemsView} - */ - this.itemsView = this._createItemsView(); - - /** - * TODO + * **Note:** Used only when {@link #shouldGroupWhenFull} is `true`. * * @readonly * @protected * @member {Boolean} */ - this._updateLock = false; + this._updateGroupedItemsLock = false; /** - * TODO + * 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. + * + * **Note:** Set only when {@link #shouldGroupWhenFull} is `true`. * * @readonly * @protected @@ -147,19 +184,26 @@ export default class ToolbarView extends View { this._horizontalPadding = null; /** - * TODO + * 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 {} + * @member {module:utils/dom/getresizeobserver~ResizeObserver} */ this._resizeObserver = null; /** + * 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.). + * * ┌────────────────────────────────── #_components ──────────────────────────────────────┐ * | | * | ┌──── #itemsView───────┐ ┌──────────────────────┐ ┌──────────────────────────┐ | - * | | ... | | ToolbarSeparatorView | | #overflowedItemsDropdown | | + * | | ... | | ToolbarSeparatorView | | #groupedItemsDropdown | | * | └─────────────────────-┘ └──────────────────────┘ └──────────────────────────┘ | * | \---------- only when #shouldGroupWhenFull ---------/ | * +──────────────────────────────────────────────────────────────────────────────────────┘ @@ -172,7 +216,10 @@ export default class ToolbarView extends View { this._components.add( this.itemsView ); /** - * Helps cycling over focusable {@link #items} in the toolbar. + * 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 @@ -183,15 +230,25 @@ export default class ToolbarView extends View { 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}. + * + * @readonly + * @protected + * @member {module:ui/focuscycler~FocusCycler} + */ this._componentsFocusCycler = new FocusCycler( { focusables: this._components, focusTracker: this.focusTracker, } ); - this.keystrokes.set( 'arrowleft', this._onKeyboardPrevious.bind( this ) ); - this.keystrokes.set( 'arrowup', this._onKeyboardPrevious.bind( this ) ); - this.keystrokes.set( 'arrowright', this._onKeyboardNext.bind( this ) ); - this.keystrokes.set( 'arrowdown', this._onKeyboardNext.bind( this ) ); + 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 ) ); this.setTemplate( { tag: 'div', @@ -236,11 +293,11 @@ export default class ToolbarView extends View { } ); this.items.on( 'add', () => { - this.update(); + this._updateGroupedItems(); } ); this.items.on( 'remove', () => { - this.update(); + this._updateGroupedItems(); } ); // Start listening for the keystrokes coming from #element. @@ -253,8 +310,8 @@ export default class ToolbarView extends View { destroy() { // The dropdown may not be in #items at the moment of toolbar destruction // so let's make sure it's actually destroyed along with the toolbar. - if ( this.overflowedItemsDropdown ) { - this.overflowedItemsDropdown.destroy(); + if ( this.groupedItemsDropdown ) { + this.groupedItemsDropdown.destroy(); } if ( this._resizeObserver ) { @@ -319,36 +376,40 @@ export default class ToolbarView extends View { } /** - * When called, if {@link #shouldGroupWhenFull} is `true`, it will check if any of the {@link #items} overflow - * and if so, it will move it to the {@link #overflowedItemsDropdown}. + * 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 - * "grouped" items in the {@link #overflowedItemsDropdown} to be returned back. + * {@link #groupedItems} to be returned back to {@link #items} and still fit into a single row + * without the toolbar wrapping. */ - update() { + _updateGroupedItems() { if ( !this.shouldGroupWhenFull ) { return; } - // Do not check when another check is going to avoid infinite loops. - // This method is called upon adding and removing #items and it adds and removes - // #items itself, so that would be a disaster. - if ( this._updateLock ) { + // 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._updateGroupedItemsLock ) { return; } - // There's no way to check overflow when there is no element (before #render()). - // Or when element has no parent because ClientRects won't work when #element not in DOM. + // 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. if ( !this.element || !this.element.parentNode ) { return; } - this._updateLock = true; + this._updateGroupedItemsLock = true; let wereItemsGrouped; - // Group #items as long as any overflows. This will happen, for instance, - // when the toolbar is getting narrower and there's less and less space in it. + // 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(); @@ -356,49 +417,33 @@ export default class ToolbarView extends View { } // If none were grouped now but there were some items already grouped before, - // then maybe let's see if some of them can be ungrouped. This happens when, + // 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._hasOverflowedItemsDropdown ) { + if ( !wereItemsGrouped && this.groupedItems && this.groupedItems.length ) { // Ungroup items as long as none are overflowing or there are none to ungroup left. - while ( this._overflowedItems.length && !this._areItemsOverflowing ) { + while ( this.groupedItems.length && !this._areItemsOverflowing ) { this._ungroupFirstItem(); } - // If the ungrouping ended up with some item overflowing, - // put it back to the group toolbar (undo the last ungroup). We don't know whether - // an item will overflow or not until we ungroup it (that's a DOM/CSS thing) so this - // clean–up is vital. + // 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._updateLock = false; + this._updateGroupedItemsLock = false; } /** - * TODO + * Returns `true` when any of toolbar {@link #items} visually overflows, for instance if the + * toolbar is narrower than its members. `false` otherwise. * - * @protected - * @type {module:ui/viewcollection~ViewCollection} - */ - get _overflowedItems() { - return this.overflowedItemsDropdown.toolbarView.items; - } - - /** - * TODO - * - * @protected - * @type {Boolean} - */ - get _hasOverflowedItemsDropdown() { - return this.overflowedItemsDropdown && this._components.has( this.overflowedItemsDropdown ); - } - - /** - * Returns `true` when any of toolbar {@link #items} overflows visually. - * `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} @@ -431,7 +476,7 @@ export default class ToolbarView extends View { } /** - * TODO + * Creates the {@link #itemsView} that hosts the members of the {@link #items} collection. * * @protected * @returns {module:ui/view~View} @@ -446,10 +491,10 @@ export default class ToolbarView extends View { } /** - * Creates the {@link #overflowedItemsDropdown} on demand. Used when the space in the toolbar - * is scarce and some items start overflow and need grouping. + * 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. * - * See {@link #shouldGroupWhenFull}. + * **Note:** Invoked on demand. See {@link #shouldGroupWhenFull} to learn more. * * @protected * @returns {module:ui/dropdown/dropdownview~DropdownView} @@ -457,34 +502,41 @@ export default class ToolbarView extends View { _createOverflowedItemsDropdown() { const t = this.t; const locale = this.locale; - const overflowedItemsDropdown = createDropdown( locale ); + const groupedItemsDropdown = createDropdown( locale ); - overflowedItemsDropdown.class = 'ck-toolbar__grouped-dropdown'; - addToolbarToDropdown( overflowedItemsDropdown, [] ); + groupedItemsDropdown.class = 'ck-toolbar__grouped-dropdown'; + addToolbarToDropdown( groupedItemsDropdown, [] ); - overflowedItemsDropdown.buttonView.set( { + groupedItemsDropdown.buttonView.set( { label: t( 'Show more items' ), tooltip: true, icon: verticalDotsIcon } ); - return overflowedItemsDropdown; + this.groupedItems = groupedItemsDropdown.toolbarView.items; + + 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 ──────|─┐ ┌───────|──────────|───────┐ | | - * | ▲ | \───▶──────────▶───/ | | #overflowedItemsDropdown | ▼ | + * | ▲ | \───▶──────────▶───/ | | #groupedItemsDropdown | ▼ | * | | └─────────────────────-┘ └──────────────────────────┘ | | * | | | | * | └─────◀───────────◀────────────◀──────────────◀──────────────◀─────────────/ | * | | * +────────────────────────────────────────────────────────────────────────────────────┘ */ - _onKeyboardNext( keyEvtData, cancel ) { + _focusNext( keyEvtData, cancel ) { if ( this.itemsView.focusTracker.isFocused ) { if ( this._itemsFocusCycler.next === this._itemsFocusCycler.first ) { this._componentsFocusCycler.focusNext(); @@ -501,22 +553,29 @@ export default class ToolbarView extends View { } /** + * 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. + * * ┌────────────────────────────── #_components ────────────────────────────────────────┐ * | | * | /────◀────\ /───────◀───────◀───────\ /────◀─────\ | * | | ▲ ▼ ▲ ▼ | | * | | ┌─|──── #items ──────|─┐ ┌───────|──────────|───────┐ | | - * | ▼ | \───◀──────────◀───/ | | #overflowedItemsDropdown | ▲ | + * | ▼ | \───◀──────────◀───/ | | #groupedItemsDropdown | ▲ | * | | └─────────────────────-┘ └──────────────────────────┘ | | * | | | | * | └─────▶───────────▶────────────▶──────────────▶──────────────▶─────────────/ | * | | * +────────────────────────────────────────────────────────────────────────────────────┘ */ - _onKeyboardPrevious( keyEvtData, cancel ) { + _focusPrevious( keyEvtData, cancel ) { if ( this.itemsView.focusTracker.isFocused ) { if ( this._itemsFocusCycler.previous === this._itemsFocusCycler.last ) { - if ( this._hasOverflowedItemsDropdown ) { + const hasGroupedItemsDropdown = this.groupedItemsDropdown && this._components.has( this.groupedItemsDropdown ); + + if ( hasGroupedItemsDropdown ) { this._componentsFocusCycler.focusLast(); } else { this._itemsFocusCycler.focusPrevious(); @@ -538,15 +597,15 @@ export default class ToolbarView extends View { } /** - * Enables the toolbar functionality that prevents its {@link #items} from overflow - * when the space becomes scarce. Instead, the toolbar items are grouped under the - * {@link #overflowedItemsDropdown dropdown} displayed at the end of the space, which offers its own - * nested toolbar. + * 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 children and "group" + * 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 to the - * main {@link #element}, depending on the visual space available. + * the future and respond to them by grouping more items or reverting already grouped back, depending + * on the visual space available. * * **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 @@ -557,66 +616,90 @@ export default class ToolbarView extends View { return; } - let oldRect; + let previousWidth; - // TODO: stopObserving on destroy(); this._resizeObserver = getResizeObserver( ( [ entry ] ) => { - if ( !oldRect || oldRect.width !== entry.contentRect.width ) { - this.update(); + if ( !previousWidth || previousWidth.width !== entry.contentRect.width ) { + this._updateGroupedItems(); } - oldRect = entry.contentRect.width; - } ).observe( this.element ); + previousWidth = entry.contentRect.width; + } ); - this.update(); + this._resizeObserver.observe( this.element ); + + this._updateGroupedItems(); } /** - * When called it will remove the last {@link #_lastNonGroupedItem regular item} from {@link #items} - * and move it to the {@link #overflowedItemsDropdown}. The opposite of {@link _ungroupFirstItem}. + * 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}). * - * If the dropdown does not exist or does not belong to {@link #items} it is created and located at - * the end of the collection. + * If the {@link #groupedItemsDropdown} does not exist, it is created and added to {@link #_components}. * * @protected */ _groupLastItem() { - if ( !this.overflowedItemsDropdown ) { - this.overflowedItemsDropdown = this._createOverflowedItemsDropdown(); + if ( !this.groupedItemsDropdown ) { + this.groupedItemsDropdown = this._createOverflowedItemsDropdown(); } - if ( !this._hasOverflowedItemsDropdown ) { + if ( !this._components.has( this.groupedItemsDropdown ) ) { this._components.add( new ToolbarSeparatorView() ); - this._components.add( this.overflowedItemsDropdown ); + this._components.add( this.groupedItemsDropdown ); } - this._overflowedItems.add( this.items.remove( this.items.last ), 0 ); + this.groupedItems.add( this.items.remove( this.items.last ), 0 ); } /** - * Moves the very first item from the toolbar belonging to {@link #overflowedItemsDropdown} back - * to the {@link #items} collection. + * The opposite of {@link #_groupLastItem}. * - * In some way, it's 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._overflowedItems.remove( this._overflowedItems.first ) ); + this.items.add( this.groupedItems.remove( this.groupedItems.first ) ); - if ( !this._overflowedItems.length ) { - this._components.remove( this.overflowedItemsDropdown ); + if ( !this.groupedItems.length ) { + this._components.remove( this.groupedItemsDropdown ); this._components.remove( this._components.last ); } } } +/** + * 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(); /** @@ -643,6 +726,9 @@ class ToolbarItemsView extends View { } ); } + /** + * @inheritDoc + */ render() { super.render(); From f4c45d9d1fab0a18a51369d5c2c5478142be819e Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 11 Sep 2019 15:55:45 +0200 Subject: [PATCH 12/39] Docs: Improved ToolbarView docs. --- src/toolbar/toolbarview.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 90e88363..f43f5f40 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -27,6 +27,15 @@ 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 */ @@ -200,13 +209,7 @@ export default class ToolbarView extends View { * 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.). * - * ┌────────────────────────────────── #_components ──────────────────────────────────────┐ - * | | - * | ┌──── #itemsView───────┐ ┌──────────────────────┐ ┌──────────────────────────┐ | - * | | ... | | ToolbarSeparatorView | | #groupedItemsDropdown | | - * | └─────────────────────-┘ └──────────────────────┘ └──────────────────────────┘ | - * | \---------- only when #shouldGroupWhenFull ---------/ | - * +──────────────────────────────────────────────────────────────────────────────────────┘ + * Please refer to the diagram in the documentation of the class to learn more. * * @readonly * @protected From ab1884b2955094c8a8aa5dd589472dd2119a5a9b Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 11 Sep 2019 16:04:18 +0200 Subject: [PATCH 13/39] Code refac: Renamd ToolbarView method. Updated docs. --- src/toolbar/toolbarview.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index f43f5f40..ad746e6e 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -165,8 +165,8 @@ export default class ToolbarView extends View { } ); /** - * 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} + * 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. * @@ -179,7 +179,7 @@ export default class ToolbarView extends View { this._updateGroupedItemsLock = false; /** - * A cached value of the horizontal padding style used by {@link #_updateGroupedItems} + * 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. @@ -296,11 +296,11 @@ export default class ToolbarView extends View { } ); this.items.on( 'add', () => { - this._updateGroupedItems(); + this.updateGroupedItems(); } ); this.items.on( 'remove', () => { - this._updateGroupedItems(); + this.updateGroupedItems(); } ); // Start listening for the keystrokes coming from #element. @@ -387,7 +387,7 @@ export default class ToolbarView extends View { * {@link #groupedItems} to be returned back to {@link #items} and still fit into a single row * without the toolbar wrapping. */ - _updateGroupedItems() { + updateGroupedItems() { if ( !this.shouldGroupWhenFull ) { return; } @@ -623,7 +623,7 @@ export default class ToolbarView extends View { this._resizeObserver = getResizeObserver( ( [ entry ] ) => { if ( !previousWidth || previousWidth.width !== entry.contentRect.width ) { - this._updateGroupedItems(); + this.updateGroupedItems(); } previousWidth = entry.contentRect.width; @@ -631,7 +631,7 @@ export default class ToolbarView extends View { this._resizeObserver.observe( this.element ); - this._updateGroupedItems(); + this.updateGroupedItems(); } /** From bc55b5bd18d14e39350b840efcb7562a98ffe492 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Thu, 12 Sep 2019 15:48:57 +0200 Subject: [PATCH 14/39] Code refactoring and tests in the ToolbarView class. --- src/toolbar/toolbarview.js | 16 ++- tests/toolbar/toolbarview.js | 272 ++++++++++++++++++++++++++++------- 2 files changed, 229 insertions(+), 59 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index ad746e6e..b69e7bd3 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -173,7 +173,7 @@ export default class ToolbarView extends View { * **Note:** Used only when {@link #shouldGroupWhenFull} is `true`. * * @readonly - * @protected + * @private * @member {Boolean} */ this._updateGroupedItemsLock = false; @@ -199,7 +199,7 @@ export default class ToolbarView extends View { * **Note:** Created dynamically only when {@link #shouldGroupWhenFull} is `true`. * * @readonly - * @protected + * @private * @member {module:utils/dom/getresizeobserver~ResizeObserver} */ this._resizeObserver = null; @@ -335,7 +335,13 @@ export default class ToolbarView extends View { * Focuses the last focusable in {@link #items}. */ focusLast() { - this._componentsFocusCycler.focusLast(); + const last = this._componentsFocusCycler.last; + + if ( last === this.itemsView ) { + this.itemsView._focusCycler.focusLast(); + } else { + this._componentsFocusCycler.focusLast(); + } } /** @@ -541,7 +547,7 @@ export default class ToolbarView extends View { */ _focusNext( keyEvtData, cancel ) { if ( this.itemsView.focusTracker.isFocused ) { - if ( this._itemsFocusCycler.next === this._itemsFocusCycler.first ) { + if ( !this._itemsFocusCycler.next || this._itemsFocusCycler.next === this._itemsFocusCycler.first ) { this._componentsFocusCycler.focusNext(); } else { this._itemsFocusCycler.focusNext(); @@ -575,7 +581,7 @@ export default class ToolbarView extends View { */ _focusPrevious( keyEvtData, cancel ) { if ( this.itemsView.focusTracker.isFocused ) { - if ( this._itemsFocusCycler.previous === this._itemsFocusCycler.last ) { + if ( !this._itemsFocusCycler.next || this._itemsFocusCycler.previous === this._itemsFocusCycler.last ) { const hasGroupedItemsDropdown = this.groupedItemsDropdown && this._components.has( this.groupedItemsDropdown ); if ( hasGroupedItemsDropdown ) { diff --git a/tests/toolbar/toolbarview.js b/tests/toolbar/toolbarview.js index 7286d930..44c688da 100644 --- a/tests/toolbar/toolbarview.js +++ b/tests/toolbar/toolbarview.js @@ -56,10 +56,14 @@ describe( 'ToolbarView', () => { expect( view.isVertical ).to.be.false; } ); - it( 'should create view#children collection', () => { + 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 ); } ); @@ -68,8 +72,28 @@ describe( 'ToolbarView', () => { expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); } ); - it( 'creates #_focusCycler instance', () => { - expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); + it( 'should create view#itemsView', () => { + 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( 'creates #_itemsFocusCycler instance', () => { + expect( view._itemsFocusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + it( 'creates #_componentsFocusCycler instance', () => { + expect( view._componentsFocusCycler ).to.be.instanceOf( FocusCycler ); } ); } ); @@ -79,6 +103,12 @@ describe( 'ToolbarView', () => { expect( view.element.classList.contains( 'ck-toolbar' ) ).to.true; } ); + it( 'should create #itemsView from template', () => { + expect( view.element.firstChild ).to.equal( view.itemsView.element ); + expect( view.itemsView.element.classList.contains( 'ck' ) ).to.true; + expect( view.itemsView.element.classList.contains( 'ck-toolbar__items' ) ).to.true; + } ); + describe( 'attributes', () => { it( 'should be defined', () => { expect( view.element.getAttribute( 'role' ) ).to.equal( 'toolbar' ); @@ -136,23 +166,33 @@ 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 #items in #focusTracker', () => { + it( 'registers #_components in #focusTracker', () => { const view = new ToolbarView( locale ); const spyAdd = sinon.spy( view.focusTracker, 'add' ); const spyRemove = sinon.spy( view.focusTracker, 'remove' ); - view.items.add( focusable() ); - view.items.add( focusable() ); + view._components.add( focusable() ); + view._components.add( focusable() ); sinon.assert.notCalled( spyAdd ); view.render(); - sinon.assert.calledTwice( spyAdd ); - view.items.remove( 1 ); + // First call is for the #itemsView. + sinon.assert.calledThrice( spyAdd ); + + view._components.remove( 1 ); sinon.assert.calledOnce( spyRemove ); view.destroy(); @@ -171,11 +211,7 @@ describe( 'ToolbarView', () => { describe( 'activates keyboard navigation for the toolbar', () => { it( 'so "arrowup" focuses previous focusable item', () => { - const keyEvtData = { - keyCode: keyCodes.arrowup, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; + const keyEvtData = getArrowKeyData( 'arrowup' ); // No children to focus. view.keystrokes.press( keyEvtData ); @@ -195,44 +231,33 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); // Mock the last item is focused. - view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.items.get( 4 ).element; + view.itemsView.focusTracker.isFocused = true; + view.itemsView.focusTracker.focusedElement = view.items.get( 4 ).element; - const spy = sinon.spy( view.items.get( 2 ), 'focus' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledThrice( keyEvtData.preventDefault ); sinon.assert.calledThrice( keyEvtData.stopPropagation ); - sinon.assert.calledOnce( spy ); + sinon.assert.calledOnce( view.items.get( 2 ).focus ); } ); it( 'so "arrowleft" focuses previous focusable item', () => { - const keyEvtData = { - keyCode: keyCodes.arrowleft, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; + const keyEvtData = getArrowKeyData( 'arrowleft' ); view.items.add( focusable() ); view.items.add( nonFocusable() ); view.items.add( focusable() ); // Mock the last item is focused. - view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.items.get( 2 ).element; - - const spy = sinon.spy( view.items.get( 0 ), 'focus' ); + view.itemsView.focusTracker.isFocused = true; + view.itemsView.focusTracker.focusedElement = view.items.get( 2 ).element; view.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( spy ); + sinon.assert.calledOnce( view.items.get( 0 ).focus ); } ); it( 'so "arrowdown" focuses next focusable item', () => { - const keyEvtData = { - keyCode: keyCodes.arrowdown, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; + const keyEvtData = getArrowKeyData( 'arrowdown' ); // No children to focus. view.keystrokes.press( keyEvtData ); @@ -252,42 +277,125 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); // Mock the last item is focused. - view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.items.get( 4 ).element; + view.itemsView.focusTracker.isFocused = true; + view.itemsView.focusTracker.focusedElement = view.items.get( 4 ).element; - const spy = sinon.spy( view.items.get( 2 ), 'focus' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledThrice( keyEvtData.preventDefault ); sinon.assert.calledThrice( keyEvtData.stopPropagation ); - sinon.assert.calledOnce( spy ); + sinon.assert.calledOnce( view.items.get( 2 ).focus ); } ); it( 'so "arrowright" focuses next focusable item', () => { - const keyEvtData = { - keyCode: keyCodes.arrowright, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; + const keyEvtData = getArrowKeyData( 'arrowright' ); view.items.add( focusable() ); view.items.add( nonFocusable() ); view.items.add( focusable() ); // Mock the last item is focused. - view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.items.get( 0 ).element; - - const spy = sinon.spy( view.items.get( 2 ), 'focus' ); + view.itemsView.focusTracker.isFocused = true; + view.itemsView.focusTracker.focusedElement = view.items.get( 0 ).element; view.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( spy ); + 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; + + view.keystrokes.press( keyEvtData ); + + sinon.assert.calledOnce( view.items.get( 0 ).focus ); + } ); } ); } ); } ); describe( 'focus()', () => { - it( 'focuses the first focusable item in DOM', () => { + it( 'focuses the first focusable of #items in DOM', () => { // No children to focus. view.focus(); @@ -296,15 +404,14 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); view.items.add( nonFocusable() ); - const spy = sinon.spy( view.items.get( 1 ), 'focus' ); view.focus(); - sinon.assert.calledOnce( spy ); + sinon.assert.calledOnce( view.items.get( 1 ).focus ); } ); } ); describe( 'focusLast()', () => { - it( 'focuses the last focusable item in DOM', () => { + it( 'focuses the last focusable of #items in DOM', () => { // No children to focus. view.focusLast(); @@ -315,10 +422,29 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); view.items.add( nonFocusable() ); - const spy = sinon.spy( view.items.get( 3 ), 'focus' ); view.focusLast(); - sinon.assert.calledOnce( spy ); + 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() ); + + view.updateGroupedItems(); + + sinon.spy( view.groupedItemsDropdown, 'focus' ); + + view.focusLast(); + + sinon.assert.calledOnce( view.groupedItemsDropdown.focus ); + + view.element.remove(); } ); } ); @@ -366,14 +492,44 @@ describe( 'ToolbarView', () => { function focusable() { const view = nonFocusable(); - view.focus = () => {}; + view.label = 'focusable'; + view.focus = sinon.stub().callsFake( () => { + view.element.focus(); + } ); + + view.extendTemplate( { + attributes: { + tabindex: -1 + } + } ); return view; } function nonFocusable() { const view = new View(); - view.element = document.createElement( 'li' ); + + view.set( 'label', 'non-focusable' ); + + const bind = view.bindTemplate; + + view.setTemplate( { + tag: 'div', + attributes: { + style: { + padding: '0', + margin: '0', + width: '100px', + height: '100px', + outline: '1px solid green' + } + }, + children: [ + { + text: bind.to( 'label' ) + } + ] + } ); return view; } @@ -388,3 +544,11 @@ function namedFactory( name ) { return view; }; } + +function getArrowKeyData( arrow ) { + return { + keyCode: keyCodes[ arrow ], + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; +} From 51562edfe04281629d8c1ffd06223978c21bc082 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Fri, 13 Sep 2019 13:19:46 +0200 Subject: [PATCH 15/39] Tests and code refactoring in ToolbarView. --- src/toolbar/toolbarview.js | 25 +- tests/toolbar/toolbarview.js | 337 ++++++++++++++++++++++++++- theme/components/toolbar/toolbar.css | 8 - 3 files changed, 346 insertions(+), 24 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index b69e7bd3..42b46718 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -158,11 +158,19 @@ export default class ToolbarView extends View { 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' } ); /** * A flag used by {@link #updateGroupedItems} method to make sure no concurrent updates @@ -311,7 +319,7 @@ export default class ToolbarView extends View { * @inheritDoc */ destroy() { - // The dropdown may not be in #items at the moment of toolbar destruction + // 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(); @@ -582,9 +590,7 @@ export default class ToolbarView extends View { _focusPrevious( keyEvtData, cancel ) { if ( this.itemsView.focusTracker.isFocused ) { if ( !this._itemsFocusCycler.next || this._itemsFocusCycler.previous === this._itemsFocusCycler.last ) { - const hasGroupedItemsDropdown = this.groupedItemsDropdown && this._components.has( this.groupedItemsDropdown ); - - if ( hasGroupedItemsDropdown ) { + if ( this.groupedItems && this.groupedItems.length ) { this._componentsFocusCycler.focusLast(); } else { this._itemsFocusCycler.focusPrevious(); @@ -621,12 +627,9 @@ export default class ToolbarView extends View { * the geometry of the toolbar items — they depend on the toolbar to be visible in DOM. */ _enableOverflowedItemsGroupingOnResize() { - if ( this._resizeObserver ) { - return; - } - let previousWidth; + // TODO: Consider debounce. this._resizeObserver = getResizeObserver( ( [ entry ] ) => { if ( !previousWidth || previousWidth.width !== entry.contentRect.width ) { this.updateGroupedItems(); @@ -741,10 +744,6 @@ class ToolbarItemsView extends View { render() { super.render(); - for ( const item of this.items ) { - this.focusTracker.add( item.element ); - } - this.items.on( 'add', ( evt, item ) => { this.focusTracker.add( item.element ); } ); diff --git a/tests/toolbar/toolbarview.js b/tests/toolbar/toolbarview.js index 44c688da..35c8b735 100644 --- a/tests/toolbar/toolbarview.js +++ b/tests/toolbar/toolbarview.js @@ -13,6 +13,7 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import FocusCycler from '../../src/focuscycler'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import ViewCollection from '../../src/viewcollection'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import View from '../../src/view'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import { add as addTranslations, _clear as clearTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service'; @@ -95,6 +96,66 @@ describe( 'ToolbarView', () => { 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 ); + } ); + + // 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._resizeObserver ).to.be.null; + + view.shouldGroupWhenFull = true; + + sinon.assert.calledOnce( view._resizeObserver.observe ); + sinon.assert.calledWithExactly( view._resizeObserver.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._resizeObserver ).to.be.null; + + view.shouldGroupWhenFull = true; + view._resizeObserver.callback( [ + { contentRect: { width: 42 } } + ] ); + + sinon.assert.calledTwice( view.updateGroupedItems ); + } ); + } ); } ); describe( 'template', () => { @@ -394,6 +455,61 @@ describe( 'ToolbarView', () => { } ); } ); + 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 ); + + view.shouldGroupWhenFull = true; + + // The dropdown shows up. + view.updateGroupedItems(); + 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(); + + view.destroy(); + sinon.assert.calledOnce( view.groupedItemsDropdown.destroy ); + + view.element.remove(); + } ); + + it( 'disconnects the #_resizeObserver', () => { + 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._resizeObserver, 'disconnect' ); + + view.destroy(); + sinon.assert.calledOnce( view._resizeObserver.disconnect ); + view.element.remove(); + } ); + } ); + describe( 'focus()', () => { it( 'focuses the first focusable of #items in DOM', () => { // No children to focus. @@ -436,8 +552,6 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); view.items.add( focusable() ); - view.updateGroupedItems(); - sinon.spy( view.groupedItemsDropdown, 'focus' ); view.focusLast(); @@ -487,6 +601,223 @@ describe( 'ToolbarView', () => { ); } ); } ); + + describe( 'updateGroupedItems()', () => { + beforeEach( () => { + document.body.appendChild( view.element ); + view.element.style.width = '200px'; + } ); + + afterEach( () => { + view.element.remove(); + } ); + + it( 'only works when #shouldGroupWhenFull', () => { + view.items.add( focusable() ); + view.items.add( focusable() ); + view.items.add( focusable() ); + view.items.add( focusable() ); + + view.updateGroupedItems(); + + expect( view.items ).to.have.length( 4 ); + expect( view.groupedItems ).to.be.null; + } ); + + it( 'does not throw when the view element has no geometry', () => { + view.element.remove(); + + expect( () => { + view.updateGroupedItems(); + } ).to.not.throw(); + } ); + + it( 'does not group when items fit', () => { + const itemA = focusable(); + const itemB = focusable(); + + view.items.add( itemA ); + view.items.add( itemB ); + + view.shouldGroupWhenFull = true; + + expect( view.groupedItems ).to.be.null; + expect( view.groupedItemsDropdown ).to.be.null; + } ); + + it( 'groups items that overflow into #groupedItemsDropdown', () => { + 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; + + 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 ); + } ); + + it( 'ungroups items from #groupedItemsDropdown if there is enough space to display them (all)', () => { + const itemA = focusable(); + const itemB = focusable(); + const itemC = focusable(); + const itemD = focusable(); + + view.items.add( itemA ); + view.items.add( itemB ); + view.items.add( itemC ); + view.items.add( itemD ); + + 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 ] ); + + 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 ] ); + } ); + + it( 'ungroups items from #groupedItemsDropdown if there is enough space to display them (some)', () => { + const itemA = focusable(); + const itemB = focusable(); + const itemC = focusable(); + + view.items.add( itemA ); + view.items.add( itemB ); + view.items.add( itemC ); + + view.shouldGroupWhenFull = true; + + expect( view.items.map( i => i ) ).to.have.members( [ itemA ] ); + expect( view.groupedItems.map( i => i ) ).to.have.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 ); + } ); + + describe( '#groupedItemsDropdown', () => { + it( 'has proper DOM structure', () => { + view.items.add( focusable() ); + view.items.add( focusable() ); + view.items.add( focusable() ); + view.items.add( focusable() ); + + view.shouldGroupWhenFull = true; + + const dropdown = view.groupedItemsDropdown; + + 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' ); + } ); + + it( 'shares its toolbarView#items with ToolbarView#groupedItems', () => { + view.items.add( focusable() ); + view.items.add( focusable() ); + view.items.add( focusable() ); + view.items.add( focusable() ); + + view.shouldGroupWhenFull = true; + + expect( view.groupedItemsDropdown.toolbarView.items ).to.equal( view.groupedItems ); + } ); + } ); + + describe( '#items overflow checking logic', () => { + it( 'considers the right padding of the toolbar (LTR UI)', () => { + view.class = 'ck-reset_all'; + view.element.style.width = '210px'; + view.element.style.paddingLeft = '0px'; + view.element.style.paddingRight = '20px'; + + view.items.add( focusable() ); + view.items.add( focusable() ); + + view.shouldGroupWhenFull = true; + + expect( view.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 ); + + view.extendTemplate( { + attributes: { + dir: locale.uiLanguageDirection + } + } ); + + view.render(); + document.body.appendChild( view.element ); + + view.class = 'ck-reset_all'; + view.element.style.width = '210px'; + view.element.style.paddingLeft = '20px'; + view.element.style.paddingRight = '0px'; + + view.items.add( focusable() ); + view.items.add( focusable() ); + + view.shouldGroupWhenFull = true; + + expect( view.groupedItems ).to.have.length( 1 ); + + view.destroy(); + view.element.remove(); + } ); + } ); + } ); + + describe( 'automatic toolbar grouping (#shouldGroupWhenFull = true)', () => { + 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 ); + + view.items.add( focusable() ); + sinon.assert.calledOnce( view.updateGroupedItems ); + + view.items.remove( 0 ); + sinon.assert.calledTwice( view.updateGroupedItems ); + } ); + + it( 'updates the UI when the toolbar is being resized (expanding)', () => { + // TODO + } ); + + it( 'updates the UI when the toolbar is being resized (narrowing)', () => { + // TODO + } ); + } ); } ); function focusable() { @@ -521,7 +852,7 @@ function nonFocusable() { margin: '0', width: '100px', height: '100px', - outline: '1px solid green' + background: 'rgba(255,0,0,.3)' } }, children: [ diff --git a/theme/components/toolbar/toolbar.css b/theme/components/toolbar/toolbar.css index 39838b99..b3cef7e6 100644 --- a/theme/components/toolbar/toolbar.css +++ b/theme/components/toolbar/toolbar.css @@ -46,14 +46,6 @@ } & > .ck.ck-toolbar__grouped-dropdown { - /* - * Dropdown button has asymmetric padding to fit the arrow. - * This button has no arrow so let's revert that padding back to normal. - */ - & > .ck.ck-button.ck-dropdown__button { - padding-left: var(--ck-spacing-tiny); - } - & > .ck-dropdown__button .ck-dropdown__arrow { display: none; } From 95b325f7c364c4d5f5df81d48b040c2280f6ca36 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Fri, 13 Sep 2019 16:08:35 +0200 Subject: [PATCH 16/39] More tests in ToolbarView. --- src/toolbar/toolbarview.js | 55 +++++++++---------- tests/toolbar/toolbarview.js | 101 ++++++++++++++++++++++++++++++++--- 2 files changed, 119 insertions(+), 37 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 42b46718..3deac51a 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -333,14 +333,18 @@ export default class ToolbarView extends View { } /** - * Focuses the first focusable in {@link #items}. + * Focuses the first focusable in element in the toolbar. */ focus() { - this._componentsFocusCycler.focusFirst(); + if ( this._itemsFocusCycler.first ) { + this._itemsFocusCycler.focusFirst(); + } else if ( this.groupedItems && this.groupedItems.length ) { + this.groupedItemsDropdown.focus(); + } } /** - * Focuses the last focusable in {@link #items}. + * Focuses the last focusable element in the toolbar. */ focusLast() { const last = this._componentsFocusCycler.last; @@ -554,16 +558,19 @@ export default class ToolbarView extends View { * +────────────────────────────────────────────────────────────────────────────────────┘ */ _focusNext( keyEvtData, cancel ) { + const itemsFocusCycler = this._itemsFocusCycler; + const componentsFocusCycler = this._componentsFocusCycler; + if ( this.itemsView.focusTracker.isFocused ) { - if ( !this._itemsFocusCycler.next || this._itemsFocusCycler.next === this._itemsFocusCycler.first ) { - this._componentsFocusCycler.focusNext(); + if ( !itemsFocusCycler.next || itemsFocusCycler.next === itemsFocusCycler.first ) { + componentsFocusCycler.focusNext(); } else { - this._itemsFocusCycler.focusNext(); + itemsFocusCycler.focusNext(); } cancel(); } else { - this._componentsFocusCycler.focusNext(); + componentsFocusCycler.focusNext(); cancel(); } @@ -588,24 +595,21 @@ export default class ToolbarView extends View { * +────────────────────────────────────────────────────────────────────────────────────┘ */ _focusPrevious( keyEvtData, cancel ) { + const itemsFocusCycler = this._itemsFocusCycler; + const componentsFocusCycler = this._componentsFocusCycler; + if ( this.itemsView.focusTracker.isFocused ) { - if ( !this._itemsFocusCycler.next || this._itemsFocusCycler.previous === this._itemsFocusCycler.last ) { - if ( this.groupedItems && this.groupedItems.length ) { - this._componentsFocusCycler.focusLast(); - } else { - this._itemsFocusCycler.focusPrevious(); - } + const hasGroupedItems = this.groupedItems && this.groupedItems.length; + + if ( hasGroupedItems && ( !itemsFocusCycler.previous || itemsFocusCycler.previous === itemsFocusCycler.last ) ) { + componentsFocusCycler.focusLast(); } else { - this._itemsFocusCycler.focusPrevious(); + itemsFocusCycler.focusPrevious(); } cancel(); } else { - if ( this._componentsFocusCycler.previous === this.itemsView ) { - this._itemsFocusCycler.focusLast(); - } else { - this._componentsFocusCycler.focusPrevious(); - } + itemsFocusCycler.focusLast(); cancel(); } @@ -631,11 +635,11 @@ export default class ToolbarView extends View { // TODO: Consider debounce. this._resizeObserver = getResizeObserver( ( [ entry ] ) => { - if ( !previousWidth || previousWidth.width !== entry.contentRect.width ) { + if ( !previousWidth || previousWidth !== entry.contentRect.width ) { this.updateGroupedItems(); - } - previousWidth = entry.contentRect.width; + previousWidth = entry.contentRect.width; + } } ); this._resizeObserver.observe( this.element ); @@ -759,11 +763,4 @@ class ToolbarItemsView extends View { focus() { this._focusCycler.focusFirst(); } - - /** - * Focuses the last focusable in {@link #items}. - */ - focusLast() { - this._focusCycler.focusLast(); - } } diff --git a/tests/toolbar/toolbarview.js b/tests/toolbar/toolbarview.js index 35c8b735..bb5a4d58 100644 --- a/tests/toolbar/toolbarview.js +++ b/tests/toolbar/toolbarview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* global document, Event, console */ +/* global document, Event, console, setTimeout */ import ToolbarView from '../../src/toolbar/toolbarview'; import ToolbarSeparatorView from '../../src/toolbar/toolbarseparatorview'; @@ -470,10 +470,8 @@ describe( 'ToolbarView', () => { view.items.add( itemC ); view.items.add( itemD ); - view.shouldGroupWhenFull = true; - // The dropdown shows up. - view.updateGroupedItems(); + view.shouldGroupWhenFull = true; sinon.spy( view.groupedItemsDropdown, 'destroy' ); view.element.style.width = '500px'; @@ -524,6 +522,20 @@ 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()', () => { @@ -790,6 +802,15 @@ describe( 'ToolbarView', () => { } ); 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 ); @@ -810,12 +831,76 @@ describe( 'ToolbarView', () => { sinon.assert.calledTwice( view.updateGroupedItems ); } ); - it( 'updates the UI when the toolbar is being resized (expanding)', () => { - // TODO + 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 ); + } ); + + 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 ); + + done(); + }, 100 ); } ); - it( 'updates the UI when the toolbar is being resized (narrowing)', () => { - // TODO + 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() ); + + view.shouldGroupWhenFull = true; + sinon.spy( view, 'updateGroupedItems' ); + + expect( view.items ).to.have.length( 5 ); + expect( view.groupedItems ).to.be.null; + + setTimeout( () => { + view.element.style.height = '500px'; + + setTimeout( () => { + sinon.assert.calledOnce( view.updateGroupedItems ); + done(); + }, 100 ); + }, 100 ); } ); } ); } ); From b382915b9f100e90bce89e80e8e5c734c9c55a21 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 16 Sep 2019 13:16:32 +0200 Subject: [PATCH 17/39] Code refactoring. --- src/toolbar/toolbarview.js | 28 ++++++++++++++-------------- tests/toolbar/toolbarview.js | 16 ++++++++-------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 3deac51a..a81f41cd 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -184,7 +184,7 @@ export default class ToolbarView extends View { * @private * @member {Boolean} */ - this._updateGroupedItemsLock = false; + this._groupWhenFullLock = false; /** * A cached value of the horizontal padding style used by {@link #updateGroupedItems} @@ -198,7 +198,7 @@ export default class ToolbarView extends View { * @protected * @member {Number} */ - this._horizontalPadding = null; + this._groupWhenFullCachedPadding = null; /** * An instance of the resize observer that helps dynamically determine the geometry of the toolbar @@ -210,7 +210,7 @@ export default class ToolbarView extends View { * @private * @member {module:utils/dom/getresizeobserver~ResizeObserver} */ - this._resizeObserver = null; + this._groupWhenFullResizeObserver = null; /** * A top–level collection aggregating building blocks of the toolbar. It mainly exists to @@ -325,8 +325,8 @@ export default class ToolbarView extends View { this.groupedItemsDropdown.destroy(); } - if ( this._resizeObserver ) { - this._resizeObserver.disconnect(); + if ( this._groupWhenFullResizeObserver ) { + this._groupWhenFullResizeObserver.disconnect(); } return super.destroy(); @@ -413,7 +413,7 @@ export default class ToolbarView extends View { // 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._updateGroupedItemsLock ) { + if ( this._groupWhenFullLock ) { return; } @@ -424,7 +424,7 @@ export default class ToolbarView extends View { return; } - this._updateGroupedItemsLock = true; + this._groupWhenFullLock = true; let wereItemsGrouped; @@ -455,7 +455,7 @@ export default class ToolbarView extends View { } } - this._updateGroupedItemsLock = false; + this._groupWhenFullLock = false; } /** @@ -479,20 +479,20 @@ export default class ToolbarView extends View { const lastChildRect = new Rect( this.element.lastChild ); const toolbarRect = new Rect( this.element ); - if ( !this._horizontalPadding ) { + 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._horizontalPadding = Number.parseInt( computedStyle[ paddingProperty ] ); + this._groupWhenFullCachedPadding = Number.parseInt( computedStyle[ paddingProperty ] ); } if ( uiLanguageDirection === 'ltr' ) { - return lastChildRect.right > toolbarRect.right - this._horizontalPadding; + return lastChildRect.right > toolbarRect.right - this._groupWhenFullCachedPadding; } else { - return lastChildRect.left < toolbarRect.left + this._horizontalPadding; + return lastChildRect.left < toolbarRect.left + this._groupWhenFullCachedPadding; } } @@ -634,7 +634,7 @@ export default class ToolbarView extends View { let previousWidth; // TODO: Consider debounce. - this._resizeObserver = getResizeObserver( ( [ entry ] ) => { + this._groupWhenFullResizeObserver = getResizeObserver( ( [ entry ] ) => { if ( !previousWidth || previousWidth !== entry.contentRect.width ) { this.updateGroupedItems(); @@ -642,7 +642,7 @@ export default class ToolbarView extends View { } } ); - this._resizeObserver.observe( this.element ); + this._groupWhenFullResizeObserver.observe( this.element ); this.updateGroupedItems(); } diff --git a/tests/toolbar/toolbarview.js b/tests/toolbar/toolbarview.js index bb5a4d58..94f3b542 100644 --- a/tests/toolbar/toolbarview.js +++ b/tests/toolbar/toolbarview.js @@ -126,12 +126,12 @@ describe( 'ToolbarView', () => { testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( FakeResizeObserver ); - expect( view._resizeObserver ).to.be.null; + expect( view._groupWhenFullResizeObserver ).to.be.null; view.shouldGroupWhenFull = true; - sinon.assert.calledOnce( view._resizeObserver.observe ); - sinon.assert.calledWithExactly( view._resizeObserver.observe, view.element ); + sinon.assert.calledOnce( view._groupWhenFullResizeObserver.observe ); + sinon.assert.calledWithExactly( view._groupWhenFullResizeObserver.observe, view.element ); } ); it( 'updates the state of grouped items upon resize', () => { @@ -146,10 +146,10 @@ describe( 'ToolbarView', () => { testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( FakeResizeObserver ); - expect( view._resizeObserver ).to.be.null; + expect( view._groupWhenFullResizeObserver ).to.be.null; view.shouldGroupWhenFull = true; - view._resizeObserver.callback( [ + view._groupWhenFullResizeObserver.callback( [ { contentRect: { width: 42 } } ] ); @@ -485,7 +485,7 @@ describe( 'ToolbarView', () => { view.element.remove(); } ); - it( 'disconnects the #_resizeObserver', () => { + it( 'disconnects the #_groupWhenFullResizeObserver', () => { document.body.appendChild( view.element ); view.element.style.width = '200px'; @@ -500,10 +500,10 @@ describe( 'ToolbarView', () => { view.items.add( itemD ); view.shouldGroupWhenFull = true; - sinon.spy( view._resizeObserver, 'disconnect' ); + sinon.spy( view._groupWhenFullResizeObserver, 'disconnect' ); view.destroy(); - sinon.assert.calledOnce( view._resizeObserver.disconnect ); + sinon.assert.calledOnce( view._groupWhenFullResizeObserver.disconnect ); view.element.remove(); } ); } ); From a197586f06a18adc89f6281dd04f9136459ec9f8 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 16 Sep 2019 13:31:44 +0200 Subject: [PATCH 18/39] Tests: Added manual test for automatic ToolbarView items grouping. --- tests/manual/toolbar/grouping.html | 11 +++++++ tests/manual/toolbar/grouping.js | 52 ++++++++++++++++++++++++++++++ tests/manual/toolbar/grouping.md | 36 +++++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 tests/manual/toolbar/grouping.html create mode 100644 tests/manual/toolbar/grouping.js create mode 100644 tests/manual/toolbar/grouping.md diff --git a/tests/manual/toolbar/grouping.html b/tests/manual/toolbar/grouping.html new file mode 100644 index 00000000..4cac73cf --- /dev/null +++ b/tests/manual/toolbar/grouping.html @@ -0,0 +1,11 @@ +

Editor with LTR UI

+ +
+

Editor content

+
+ +

Editor with RTL UI

+ +
+

Editor content

+
diff --git a/tests/manual/toolbar/grouping.js b/tests/manual/toolbar/grouping.js new file mode 100644 index 00000000..89c239db --- /dev/null +++ b/tests/manual/toolbar/grouping.js @@ -0,0 +1,52 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; + +createEditor( '#editor-ltr', 'en' ); +createEditor( '#editor-rtl', 'ar' ); + +function createEditor( selector, language ) { + ClassicEditor + .create( document.querySelector( selector ), { + plugins: [ ArticlePluginSet ], + toolbar: [ + 'heading', + '|', + 'bold', + 'italic', + 'link', + '|', + 'bulletedList', + 'numberedList', + 'blockQuote', + 'insertTable', + 'mediaEmbed', + '|', + 'undo', + 'redo' + ], + image: { + toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + }, + language + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); +} diff --git a/tests/manual/toolbar/grouping.md b/tests/manual/toolbar/grouping.md new file mode 100644 index 00000000..1f89658c --- /dev/null +++ b/tests/manual/toolbar/grouping.md @@ -0,0 +1,36 @@ +# Automatic toolbar grouping + +## Grouping on load + +1. Narrow the browser window so some toolbar items should wrap to the next row. +2. Refresh the test. +3. The toolbar should looks the same. Make sure none of toolbar items wrapped or overflow. +4. The dropdown button should be displayed at the end of the toolbar, allowing to access grouped features. +5. Grouped items toolbar should never start or end with a separator, even if one was in the main toolbar space. +6. Other separators (between items) should be preserved. + +## Grouping and ungrouping on resize + +1. Play with the size of the browser window. +2. Toolbar items should group and ungroup automatically but + * the should never wrap to the next line, + * or stick out beyond the toolbar boundaries. + +## Accessibility + +1. Make sure no toolbar items are grouped. +2. Use Alt + F10 (+ Fn on Mac) to focus the toolbar. +3. Navigate the toolbar using the keyboard + * it should work naturally, + * the navigation should cycle (leaving the last item focuses the first item, and going back from the first items focuses the last) +4. Resize the window so some items are grouped. +5. Check if navigation works in the same way but includes the button that aggregates grouped items. +6. Enter the group button, navigate across grouped items, go back (Esc). +7. There should be no interruptions or glitches in the navigation. + +## RTL UI support + +1. Perform the same scenarios in the editor with RTL (right–to–left) UI. +2. There should be no visual or behavioral difference between LTR and RTL editors except that the toolbar is mirrored. +3. The button aggregating grouped toolbar items should be displayed on the left–hand side. + From a15d3df5aeb08cdc0f815fbfa11bdf534676b763 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 16 Sep 2019 15:09:48 +0200 Subject: [PATCH 19/39] Made sure the toolbar does not cause console.warns when detached from visible DOM. --- src/toolbar/toolbarview.js | 11 ++++++++--- tests/toolbar/toolbarview.js | 14 ++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index a81f41cd..43572bce 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -417,12 +417,17 @@ export default class ToolbarView extends View { 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. - if ( !this.element || !this.element.parentNode ) { - return; - } this._groupWhenFullLock = true; diff --git a/tests/toolbar/toolbarview.js b/tests/toolbar/toolbarview.js index 94f3b542..3ce2aa89 100644 --- a/tests/toolbar/toolbarview.js +++ b/tests/toolbar/toolbarview.js @@ -636,12 +636,18 @@ describe( 'ToolbarView', () => { expect( view.groupedItems ).to.be.null; } ); - it( 'does not throw when the view element has no geometry', () => { + it( 'stays silent if the toolbar is detached from visible DOM', () => { + testUtils.sinon.spy( console, 'warn' ); view.element.remove(); - expect( () => { - view.updateGroupedItems(); - } ).to.not.throw(); + view.items.add( focusable() ); + view.items.add( focusable() ); + view.items.add( focusable() ); + view.items.add( focusable() ); + + view.shouldGroupWhenFull = true; + + sinon.assert.notCalled( console.warn ); } ); it( 'does not group when items fit', () => { From 9ede6a43c542aeeed319d365a1b215877f4ad9de Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 16 Sep 2019 15:43:26 +0200 Subject: [PATCH 20/39] Tests: Added a test to check if #updateGroupedItems is called after the #element has a proper class. --- tests/toolbar/toolbarview.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/toolbar/toolbarview.js b/tests/toolbar/toolbarview.js index 3ce2aa89..a5fc290b 100644 --- a/tests/toolbar/toolbarview.js +++ b/tests/toolbar/toolbarview.js @@ -106,6 +106,18 @@ describe( 'ToolbarView', () => { 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', () => { @@ -184,6 +196,8 @@ describe( 'ToolbarView', () => { view.render(); expect( view.element.getAttribute( 'aria-label' ) ).to.equal( 'Custom label' ); + + view.destroy(); } ); it( 'should allow the aria-label to be translated', () => { @@ -192,6 +206,8 @@ describe( 'ToolbarView', () => { view.render(); expect( view.element.getAttribute( 'aria-label' ) ).to.equal( 'Pasek narzędzi edytora' ); + + view.destroy(); } ); } ); From 75e5106d5ad5da47c35e85d49fa46b13b8b5a31f Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 16 Sep 2019 15:44:46 +0200 Subject: [PATCH 21/39] Docs: Improvements in ToolbarView docs. --- src/toolbar/toolbarview.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 43572bce..8bdb1802 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -172,6 +172,18 @@ export default class ToolbarView extends View { } }, { 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`. + * + * @readonly + * @protected + * @member {module:utils/dom/getresizeobserver~ResizeObserver} + */ + this._groupWhenFullResizeObserver = null; + /** * 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} @@ -195,23 +207,11 @@ export default class ToolbarView extends View { * **Note:** Set only when {@link #shouldGroupWhenFull} is `true`. * * @readonly - * @protected + * @private * @member {Number} */ this._groupWhenFullCachedPadding = null; - /** - * 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 - * @private - * @member {module:utils/dom/getresizeobserver~ResizeObserver} - */ - this._groupWhenFullResizeObserver = null; - /** * 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 From 0bbaebd98aab936bcd551b32d3b94ed84fa3c6d6 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 7 Oct 2019 17:26:01 +0200 Subject: [PATCH 22/39] Code refactoring. --- src/toolbar/toolbarview.js | 736 +++++++++++++++++-------------------- 1 file changed, 329 insertions(+), 407 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 8bdb1802..7e605469 100644 --- a/src/toolbar/toolbarview.js +++ b/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(); + } } } From b8b9f59c0d214c544212601b18ea47968c8343a2 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 8 Oct 2019 09:55:26 +0200 Subject: [PATCH 23/39] Code refactoring. --- src/toolbar/toolbarview.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 7e605469..387faa78 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -165,6 +165,11 @@ export default class ToolbarView extends View { */ this._groupedItemsDropdown = this._createGrouppedItemsDropdown(); + /** + * TODO + */ + this._itemsGroupper = null; + /** * 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 @@ -254,7 +259,7 @@ export default class ToolbarView extends View { // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); - this._itemsGroupper = new ToolbarItemsGroupper( { + this._itemsGroupper = new ToolbarItemsGrouper( { shouldGroupWhenFull: this.options.shouldGroupWhenFull, items: this.items, ungroupedItems: this._ungroupedItems, @@ -441,7 +446,7 @@ class UngrouppedItemsView extends View { } } -class ToolbarItemsGroupper { +class ToolbarItemsGrouper { constructor( options ) { Object.assign( this, options ); @@ -554,7 +559,7 @@ class ToolbarItemsGroupper { // 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 ) { + while ( this.areItemsOverflowing ) { this.groupLastItem(); wereItemsGrouped = true; @@ -565,7 +570,7 @@ class ToolbarItemsGroupper { // 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 ) { + while ( this.groupedItems.length && !this.areItemsOverflowing ) { this.ungroupFirstItem(); } @@ -573,7 +578,7 @@ class ToolbarItemsGroupper { // 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 ) { + if ( this.areItemsOverflowing ) { this.groupLastItem(); } } @@ -630,7 +635,7 @@ class ToolbarItemsGroupper { * @protected * @type {Boolean} */ - get areToolbarItemsOverflowing() { + get areItemsOverflowing() { // An empty toolbar cannot overflow. if ( !this.ungroupedItems.length ) { return false; From 64e58bc2eb36be446792a1bc351785166c9cd01e Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 8 Oct 2019 12:46:30 +0200 Subject: [PATCH 24/39] Docs: Updated the documentation after the refactoring. --- src/toolbar/toolbarview.js | 172 +++++++++++++++++++++++++------------ 1 file changed, 116 insertions(+), 56 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 387faa78..b28a9d43 100644 --- a/src/toolbar/toolbarview.js +++ b/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,24 +37,21 @@ 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. + * @param {module:ui/toolbar/toolbarview~ToolbarOptions} [options] Configuration options of the toolbar. */ - constructor( locale, options = {} ) { + constructor( locale, options ) { super( locale ); const bind = this.bindTemplate; const t = this.t; /** - * TODO + * A reference to the options object passed to the constructor. * * @readonly - * @member {Object} + * @member {module:ui/toolbar/toolbarview~ToolbarOptions} */ - this.options = options; + this.options = options || {}; /** * Label used by assistive technologies to describe this toolbar element. @@ -76,10 +64,6 @@ 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} */ @@ -119,63 +103,95 @@ export default class ToolbarView extends View { this.set( 'class' ); /** - * TODO + * A subset of of toolbar {@link #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 a {@link #_ungroupedItemsView dedicated view}. + * + * When none of the {@link #items} were grouped, it matches the {@link #items} collection in size and order. * + * **Note**: Grouping occurs only when the toolbar was + * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull configured}. + * + * See the {@link #_itemsManager} to learn more. + * + * @private * @readonly * @member {module:ui/viewcollection~ViewCollection} */ this._ungroupedItems = this.createCollection(); /** - * TODO + * A subset of of toolbar {@link #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}. * - * 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. + * When none of the {@link #items} were grouped, this collection is empty. * + * **Note**: Grouping occurs only when the toolbar was + * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull configured}. + * + * See the {@link #_itemsManager} to learn more. + * + * @private * @readonly * @member {module:ui/viewcollection~ViewCollection} */ this._groupedItems = this.createCollection(); /** - * A view containing toolbar {@link #items}. + * A view containing {@link #_ungroupedItems ungrouped toolbar items} (as opposed to the + * {@link #_groupedItemsDropdown} containing {@link #_groupedItems grouped toolbar 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}. + * See the {@link #_itemsManager} to learn more. * + * @private * @readonly * @member {module:ui/toolbar/toolbarview~UngrouppedItemsView} */ this._ungroupedItemsView = this._createUngrouppedItemsView(); /** - * 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. + * The dropdown that aggregates {@link #_groupedItems grouped items} that do not fit into a single + * row of the toolbar. It is displayed on demand at the end of the toolbar and offers another + * (nested) toolbar which displays items that would normally overflow. * - * **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. + * See the {@link #_itemsManager} to learn more. * + * @private * @readonly - * @member {module:ui/dropdown/dropdownview~DropdownView} #groupedItemsDropdown + * @member {module:ui/dropdown/dropdownview~DropdownView} */ this._groupedItemsDropdown = this._createGrouppedItemsDropdown(); /** - * TODO + * An instance of the utility responsible for managing the toolbar {@link #items}. + * + * For instance, it controls which of the {@link #items} should be {@link #_ungroupedItems ungrouped} or + * {@link #_groupedItems grouped} depending on the configuration of the toolbar and its geometry. + * + * **Note**: The instance is created upon {@link #render} when the {@link #element} of the toolbar + * starts to exist. + * + * @private + * @readonly + * @member {module:ui/toolbar/toolbarview~ToolbarItemsManager} */ - this._itemsGroupper = null; + this._itemsManager = null; /** * 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.). + * make sure {@link #_ungroupedItems} do not mix up with the {@link #_groupedItemsDropdown}. + * + * It helps a lot when the {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull grouping} + * logic is on (no re–ordering issues, exclusions, etc.). * - * Please refer to the diagram in the documentation of the class to learn more. + * ┌───────────────────────────────────────── ToolbarView ──────────────────────────────────────────┐ + * | ┌─────────────────────────────────────── #_components ───────────────────────────────────────┐ | + * | | ┌── #_ungroupedItemsView ───┐ ┌──────────────────────┐ ┌── #_groupedItemsDropdown ───┐ | | + * | | | #_ungroupedItems | | ToolbarSeparatorView | | #_groupedItems | | | + * | | └──────────────────────────-┘ └──────────────────────┘ └─────────────────────────────┘ | | + * | | \--------- only when toolbar items overflow ---------/ | | + * | └────────────────────────────────────────────────────────────────────────────────────────────┘ | + * └────────────────────────────────────────────────────────────────────────────────────────────────┘ * * @readonly * @protected @@ -185,12 +201,31 @@ export default class ToolbarView extends View { this._components.add( this._ungroupedItemsView ); /** - * TODO + * A helper collection that aggregates a subset of {@link #items} that is subject to the focus cycling + * (e.g. navigation using the keyboard). + * + * It contains all the items from {@link #_ungroupedItems} plus (optionally) the {@link #_groupedItemsDropdown} + * at the end. + * + * This collection is dynamic and responds to the changes in {@link #_ungroupedItems} and {@link #_components} + * so the {@link #_focusCycler focus cycler} logic operates on the up–to–date collection of items that + * are actually available for the user to focus and navigate at this particular moment. + * + * This collection is necessary because the {@link #_itemsManager} can dynamically change the content + * of the {@link #_ungroupedItems} and also spontaneously display the {@link #_groupedItemsDropdown} + * (also focusable and "cycleable"). + * + * @private + * @readonly + * @member {module:ui/viewcollection~ViewCollection} */ this._focusCycleableItems = this.createCollection(); + // Make sure all #items visible in the main space of the toolbar are cycleable. 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. this._components.on( 'add', this._updateFocusCycleableItems.bind( this ) ); this._components.on( 'remove', this._updateFocusCycleableItems.bind( this ) ); @@ -259,7 +294,8 @@ export default class ToolbarView extends View { // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); - this._itemsGroupper = new ToolbarItemsGrouper( { + // Initialize the utility that manages toolbar items. + this._itemsManager = new ToolbarItemsManager( { shouldGroupWhenFull: this.options.shouldGroupWhenFull, items: this.items, ungroupedItems: this._ungroupedItems, @@ -289,7 +325,7 @@ export default class ToolbarView extends View { // so let's make sure it's actually destroyed along with the toolbar. this._groupedItemsDropdown.destroy(); - this._itemsGroupper.destroy(); + this._itemsManager.destroy(); return super.destroy(); } @@ -349,10 +385,10 @@ export default class ToolbarView extends View { } /** - * Creates the {@link #itemsView} that hosts the members of the {@link #items} collection. + * Creates the {@link #_ungroupedItemsView} that hosts the members of the {@link #_ungroupedItems} collection. * - * @protected - * @returns {module:ui/view~View} + * @private + * @returns {module:ui/toolbar/toolbarview~UngrouppedItemsView} */ _createUngrouppedItemsView() { const ungrouppedItemsView = new UngrouppedItemsView( this.locale ); @@ -364,12 +400,10 @@ export default class ToolbarView extends View { } /** - * Creates the {@link #groupedItemsDropdown} that hosts the members of the {@link #groupedItems} + * 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 + * @private * @returns {module:ui/dropdown/dropdownview~DropdownView} */ _createGrouppedItemsDropdown() { @@ -390,13 +424,20 @@ export default class ToolbarView extends View { icon: verticalDotsIcon } ); + // 1:1 pass–through binding. groupedItemsDropdown.toolbarView.items.bindTo( this._groupedItems ).using( item => item ); return groupedItemsDropdown; } /** - * TODO + * A method that updates the {@link #_focusCycleableItems focus–cycleable items} + * collection so it represents the up–to–date state of the UI from the perspective of the user. + * + * See the {@link #_focusCycleableItems collection} documentation to learn more about the purpose + * of this method. + * + * @private */ _updateFocusCycleableItems() { this._focusCycleableItems.clear(); @@ -413,7 +454,7 @@ 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}. + * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems ungrouped items}. * * @private * @extends module:ui/view~View @@ -446,7 +487,12 @@ class UngrouppedItemsView extends View { } } -class ToolbarItemsGrouper { +/** + * A helper class that manages the presentation layer of the {@link module:ui/toolbar/toolbarview~ToolbarView}. + * + * @private + */ +class ToolbarItemsManager { constructor( options ) { Object.assign( this, options ); @@ -696,3 +742,17 @@ class ToolbarItemsGrouper { } } } + +/** + * Options passed to the {@link module:ui/toolbar/toolbarview~ToolbarView#constructor} of the toolbar. + * + * @interface module:ui/toolbar/toolbarview~ToolbarOptions + */ + +/** + * When set `true`, the toolbar will automatically group {@link module:ui/toolbar/toolbarview~ToolbarView#items} that + * would normally wrap to the next line when there is not enough space to display them in a single row, for + * instance, if the parent container is narrow. + * + * @member {Boolean} module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull + */ From c69262be70117d6a01edcf6fc58e969260213e14 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 8 Oct 2019 16:05:07 +0200 Subject: [PATCH 25/39] Docs: Updated the toolbar.js file documentation after the refactoring. --- src/toolbar/toolbarview.js | 198 +++++++++++++++++++++++-------------- 1 file changed, 126 insertions(+), 72 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index b28a9d43..95bb6433 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -255,7 +255,7 @@ export default class ToolbarView extends View { class: [ 'ck', 'ck-toolbar', - options.shouldGroupWhenFull ? 'ck-toolbar_grouping' : '', + this.options.shouldGroupWhenFull ? 'ck-toolbar_grouping' : '', bind.if( 'isVertical', 'ck-toolbar_vertical' ), bind.to( 'class' ) ], @@ -300,7 +300,7 @@ export default class ToolbarView extends View { items: this.items, ungroupedItems: this._ungroupedItems, groupedItems: this._groupedItems, - toolbarElement: this.element, + element: this.element, uiLanguageDirection: this.locale.uiLanguageDirection, onGroupStart: () => { @@ -490,59 +490,95 @@ class UngrouppedItemsView extends View { /** * A helper class that manages the presentation layer of the {@link module:ui/toolbar/toolbarview~ToolbarView}. * + * In a nutshell, it distributes the toolbar {@link module:ui/toolbar/toolbarview~ToolbarView#items} + * among its {@link module:ui/toolbar/toolbarview~ToolbarView#_groupedItems} and + * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems} + * depending on the configuration of the toolbar, the geometry and the number of items. + * * @private */ class ToolbarItemsManager { + /** + * Creates an instance of the {@link module:ui/toolbar/toolbarview~ToolbarItemsManager} class. + * + * @param {Object} options The configuration of the helper. + * @param {Boolean} options.shouldGroupWhenFull Corresponds to + * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull}. + * @param {module:ui/viewcollection~ViewCollection} options.items Corresponds to + * {@link module:ui/toolbar/toolbarview~ToolbarView#items}. + * @param {module:ui/viewcollection~ViewCollection} options.ungroupedItems Corresponds to + * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems}. + * @param {module:ui/viewcollection~ViewCollection} options.groupedItems Corresponds to + * {@link module:ui/toolbar/toolbarview~ToolbarView#_groupedItems}/ + * @param {HTMLElement} options.element Corresponds to {@link module:ui/toolbar/toolbarview~ToolbarView#element}. + * @param {String} options.uiLanguageDirection Corresponds to {@link module:utils/locale~Locale#uiLanguageDirection}. + * @param {Function} options.onGroupStart Executed when the first ungrouped toolbar item gets grouped. + * @param {Function} options.onGroupEnd Executed when the last of the grouped toolbar items just got ungrouped. + */ constructor( options ) { Object.assign( this, options ); - this.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 ); - } + /** + * @readonly + * @member {Boolean} #shouldGroupWhenFull + */ - if ( options.shouldGroupWhenFull ) { - this.update(); - } - } ); + /** + * @readonly + * @member {module:ui/viewcollection~ViewCollection} #items + */ - 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 ); - } + /** + * @readonly + * @member {module:ui/viewcollection~ViewCollection} #ungroupedItems + */ - if ( options.shouldGroupWhenFull ) { - this.update(); - } - } ); + /** + * @readonly + * @member {module:ui/viewcollection~ViewCollection} #groupedItems + */ - if ( options.shouldGroupWhenFull ) { - this.enableGroupingOnResize(); - } + /** + * @readonly + * @member {HTMLElement} #element + */ + + /** + * @readonly + * @member {String} #uiLanguageDirection + */ + + /** + * @readonly + * @member {Function} #onGroupStart Executed when the first ungrouped toolbar item gets grouped. + * Called by {@link #groupLastItem}. + */ + + /** + * @readonly + * @member {Function} #onGroupEnd Executed when the last of the grouped toolbar items just + * got ungrouped. Called by {@link #ungroupFirstItem}. + */ /** * 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`. + * **Note:** Created dynamically in {@link #enableGroupingOnResize}. * * @readonly - * @protected + * @private * @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} + * A flag used by {@link #updateGrouping} method to make sure no concurrent updates + * are performed to the {@link #ungroupedItems} and {@link #groupedItems}. Because {@link #updateGrouping} * 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`. + * **Note:** Used only if {@link #enableGroupingOnResize} was called. * * @readonly * @private @@ -551,34 +587,65 @@ class ToolbarItemsManager { this._updateLock = false; /** - * A cached value of the horizontal padding style used by {@link #update} + * A cached value of the horizontal padding style used by {@link #updateGrouping} * 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`. + * **Note:** In use only after {@link #enableGroupingOnResize} was called. * * @readonly * @private * @member {Number} */ this._cachedPadding = null; + + // ToolbarView#items is dynamic. When an item is added, it should be automatically + // represented in either grouped or ungrouped items at the right index. + this.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, there's a chance it causes the toolbar to overflow. + // Let's check this out and do the grouping if necessary. + if ( options.shouldGroupWhenFull ) { + this.updateGrouping(); + } + } + } ); + + // When an item is removed from ToolbarView#items, it should be automatically + // removed from either grouped or ungrouped items. + 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 ); + } + + // Whether removed from grouped or ungrouped items, there is a chance + // some new space is available and we could do some ungrouping. + if ( options.shouldGroupWhenFull ) { + this.updateGrouping(); + } + } ); + + if ( options.shouldGroupWhenFull ) { + this.enableGroupingOnResize(); + } } /** - * 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. + * 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 #items} and still fit into a single row + * {@link #groupedItems} to be returned back to {@link #ungroupedItems} and still fit into a single row * without the toolbar wrapping. */ - update() { - if ( !this.shouldGroupWhenFull ) { - return; - } - + updateGrouping() { // 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. @@ -590,14 +657,10 @@ class ToolbarItemsManager { // 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 ) ) { + 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._updateLock = true; let wereItemsGrouped; @@ -633,19 +696,14 @@ class ToolbarItemsManager { } /** - * 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. + * 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. * - * When called, the toolbar will automatically analyze the location of its {@link #items} and "group" + * 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. - * - * **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. */ enableGroupingOnResize() { let previousWidth; @@ -653,17 +711,20 @@ class ToolbarItemsManager { // TODO: Consider debounce. this._resizeObserver = getResizeObserver( ( [ entry ] ) => { if ( !previousWidth || previousWidth !== entry.contentRect.width ) { - this.update(); + this.updateGrouping(); previousWidth = entry.contentRect.width; } } ); - this._resizeObserver.observe( this.toolbarElement ); + this._resizeObserver.observe( this.element ); - this.update(); + this.updateGrouping(); } + /** + * Cleans up after the manager when its parent toolbar is destroyed. + */ destroy() { if ( this._resizeObserver ) { this._resizeObserver.disconnect(); @@ -671,14 +732,9 @@ class ToolbarItemsManager { } /** - * Returns `true` when any of toolbar {@link #items} visually overflows, for instance if the + * Returns `true` when {@link #element} children visually overflow, 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() { @@ -688,11 +744,11 @@ class ToolbarItemsManager { } const uiLanguageDirection = this.uiLanguageDirection; - const lastChildRect = new Rect( this.toolbarElement.lastChild ); - const toolbarRect = new Rect( this.toolbarElement ); + const lastChildRect = new Rect( this.element.lastChild ); + const toolbarRect = new Rect( this.element ); if ( !this._cachedPadding ) { - const computedStyle = global.window.getComputedStyle( this.toolbarElement ); + 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. @@ -711,10 +767,8 @@ class ToolbarItemsManager { /** * 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}). - * - * If the {@link #groupedItemsDropdown} does not exist, it is created and added to {@link #_components}. + * When called it will remove the last item from {@link #ungroupedItems} and move it to the + * {@link #groupedItems} collection. * * @protected */ @@ -730,7 +784,7 @@ class ToolbarItemsManager { * 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}). + * to the {@link #ungroupedItems} collection. * * @protected */ From 53150ae5bae8c469ad47550f6bf7db6f4c522aba Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Thu, 10 Oct 2019 12:46:23 +0200 Subject: [PATCH 26/39] Code refactoring - used composition in the ToolbarView. --- src/toolbar/toolbarview.js | 470 ++++++++++++++++++------------------- 1 file changed, 226 insertions(+), 244 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 95bb6433..d10b1274 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -86,14 +86,6 @@ export default class ToolbarView extends View { */ this.keystrokes = new KeystrokeHandler(); - /** - * Controls the orientation of toolbar items. - * - * @observable - * @member {Boolean} #isVertical - */ - this.set( 'isVertical', false ); - /** * An additional CSS class added to the {@link #element}. * @@ -103,91 +95,28 @@ export default class ToolbarView extends View { this.set( 'class' ); /** - * A subset of of toolbar {@link #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 a {@link #_ungroupedItemsView dedicated view}. - * - * When none of the {@link #items} were grouped, it matches the {@link #items} collection in size and order. - * - * **Note**: Grouping occurs only when the toolbar was - * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull configured}. + * A view containing {@link #ungroupedItems ungrouped toolbar items} (as opposed to the + * {@link #groupedItemsDropdown} containing {@link #groupedItems grouped toolbar items}). * * See the {@link #_itemsManager} to learn more. * * @private * @readonly - * @member {module:ui/viewcollection~ViewCollection} + * @member {module:ui/toolbar/toolbarview~ItemsView} */ - this._ungroupedItems = this.createCollection(); - - /** - * A subset of of toolbar {@link #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 #items} were grouped, this collection is empty. - * - * **Note**: Grouping occurs only when the toolbar was - * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull configured}. - * - * See the {@link #_itemsManager} to learn more. - * - * @private - * @readonly - * @member {module:ui/viewcollection~ViewCollection} - */ - this._groupedItems = this.createCollection(); - - /** - * A view containing {@link #_ungroupedItems ungrouped toolbar items} (as opposed to the - * {@link #_groupedItemsDropdown} containing {@link #_groupedItems grouped toolbar items}). - * - * See the {@link #_itemsManager} to learn more. - * - * @private - * @readonly - * @member {module:ui/toolbar/toolbarview~UngrouppedItemsView} - */ - this._ungroupedItemsView = this._createUngrouppedItemsView(); - - /** - * The dropdown that aggregates {@link #_groupedItems grouped items} that do not fit into a single - * row of the toolbar. It is displayed on demand at the end of the toolbar and offers another - * (nested) toolbar which displays items that would normally overflow. - * - * See the {@link #_itemsManager} to learn more. - * - * @private - * @readonly - * @member {module:ui/dropdown/dropdownview~DropdownView} - */ - this._groupedItemsDropdown = this._createGrouppedItemsDropdown(); - - /** - * An instance of the utility responsible for managing the toolbar {@link #items}. - * - * For instance, it controls which of the {@link #items} should be {@link #_ungroupedItems ungrouped} or - * {@link #_groupedItems grouped} depending on the configuration of the toolbar and its geometry. - * - * **Note**: The instance is created upon {@link #render} when the {@link #element} of the toolbar - * starts to exist. - * - * @private - * @readonly - * @member {module:ui/toolbar/toolbarview~ToolbarItemsManager} - */ - this._itemsManager = null; + this._itemsView = this._createItemsView(); /** * A top–level collection aggregating building blocks of the toolbar. It mainly exists to - * make sure {@link #_ungroupedItems} do not mix up with the {@link #_groupedItemsDropdown}. + * make sure {@link #ungroupedItems} do not mix up with the {@link #groupedItemsDropdown}. * * It helps a lot when the {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull grouping} * logic is on (no re–ordering issues, exclusions, etc.). * * ┌───────────────────────────────────────── ToolbarView ──────────────────────────────────────────┐ * | ┌─────────────────────────────────────── #_components ───────────────────────────────────────┐ | - * | | ┌── #_ungroupedItemsView ───┐ ┌──────────────────────┐ ┌── #_groupedItemsDropdown ───┐ | | - * | | | #_ungroupedItems | | ToolbarSeparatorView | | #_groupedItems | | | + * | | ┌────── #_itemsView ────────┐ ┌──────────────────────┐ ┌── #groupedItemsDropdown ───┐ | | + * | | | #ungroupedItems | | ToolbarSeparatorView | | #groupedItems | | | * | | └──────────────────────────-┘ └──────────────────────┘ └─────────────────────────────┘ | | * | | \--------- only when toolbar items overflow ---------/ | | * | └────────────────────────────────────────────────────────────────────────────────────────────┘ | @@ -198,21 +127,21 @@ export default class ToolbarView extends View { * @member {module:ui/viewcollection~ViewCollection} */ this._components = this.createCollection(); - this._components.add( this._ungroupedItemsView ); + this._components.add( this._itemsView ); /** * A helper collection that aggregates a subset of {@link #items} that is subject to the focus cycling * (e.g. navigation using the keyboard). * - * It contains all the items from {@link #_ungroupedItems} plus (optionally) the {@link #_groupedItemsDropdown} + * It contains all the items from {@link #ungroupedItems} plus (optionally) the {@link #groupedItemsDropdown} * at the end. * - * This collection is dynamic and responds to the changes in {@link #_ungroupedItems} and {@link #_components} + * This collection is dynamic and responds to the changes in {@link #ungroupedItems} and {@link #_components} * so the {@link #_focusCycler focus cycler} logic operates on the up–to–date collection of items that * are actually available for the user to focus and navigate at this particular moment. * * This collection is necessary because the {@link #_itemsManager} can dynamically change the content - * of the {@link #_ungroupedItems} and also spontaneously display the {@link #_groupedItemsDropdown} + * of the {@link #ungroupedItems} and also spontaneously display the {@link #groupedItemsDropdown} * (also focusable and "cycleable"). * * @private @@ -221,14 +150,6 @@ export default class ToolbarView extends View { */ this._focusCycleableItems = this.createCollection(); - // Make sure all #items visible in the main space of the toolbar are cycleable. - 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. - this._components.on( 'add', this._updateFocusCycleableItems.bind( this ) ); - this._components.on( 'remove', this._updateFocusCycleableItems.bind( this ) ); - /** * Helps cycling over focusable {@link #items} in the toolbar. * @@ -249,14 +170,27 @@ export default class ToolbarView extends View { } } ); + /** + * An instance of the utility responsible for managing the toolbar {@link #items}. + * + * For instance, it controls which of the {@link #items} should be {@link #ungroupedItems ungrouped} or + * {@link #groupedItems grouped} depending on the configuration of the toolbar and its geometry. + * + * **Note**: The instance is created upon {@link #render} when the {@link #element} of the toolbar + * starts to exist. + * + * @private + * @readonly + * @member {module:ui/toolbar/toolbarview~DynamicGroupingToolbar} + */ + this._kind = this.options.shouldGroupWhenFull ? new DynamicGroupingToolbar( this ) : new StaticToolbar( this ); + this.setTemplate( { tag: 'div', attributes: { class: [ 'ck', 'ck-toolbar', - this.options.shouldGroupWhenFull ? 'ck-toolbar_grouping' : '', - bind.if( 'isVertical', 'ck-toolbar_vertical' ), bind.to( 'class' ) ], role: 'toolbar', @@ -270,6 +204,8 @@ export default class ToolbarView extends View { mousedown: preventDefault( this ) } } ); + + this._kind.extendTemplate(); } /** @@ -294,38 +230,14 @@ export default class ToolbarView extends View { // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); - // Initialize the utility that manages toolbar items. - this._itemsManager = new ToolbarItemsManager( { - shouldGroupWhenFull: this.options.shouldGroupWhenFull, - items: this.items, - ungroupedItems: this._ungroupedItems, - groupedItems: this._groupedItems, - element: 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 ); - } - } ); + this._kind.render(); } /** * @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. - this._groupedItemsDropdown.destroy(); - - this._itemsManager.destroy(); + this._kind.destroy(); return super.destroy(); } @@ -385,81 +297,26 @@ export default class ToolbarView extends View { } /** - * Creates the {@link #_ungroupedItemsView} that hosts the members of the {@link #_ungroupedItems} collection. + * Creates the {@link #_itemsView} that hosts the members of the {@link #ungroupedItems} collection. * * @private - * @returns {module:ui/toolbar/toolbarview~UngrouppedItemsView} + * @returns {module:ui/toolbar/toolbarview~ItemsView} */ - _createUngrouppedItemsView() { - const ungrouppedItemsView = new UngrouppedItemsView( this.locale ); - - // 1:1 pass–through binding. - ungrouppedItemsView.items.bindTo( this._ungroupedItems ).using( item => item ); + _createItemsView() { + const ungrouppedItemsView = new ItemsView( this.locale ); return ungrouppedItemsView; } - - /** - * Creates the {@link #_groupedItemsDropdown} that hosts the members of the {@link #_groupedItems} - * collection when there is not enough space in the toolbar to display all items in a single row. - * - * @private - * @returns {module:ui/dropdown/dropdownview~DropdownView} - */ - _createGrouppedItemsDropdown() { - if ( !this.options.shouldGroupWhenFull ) { - return null; - } - - 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 - } ); - - // 1:1 pass–through binding. - groupedItemsDropdown.toolbarView.items.bindTo( this._groupedItems ).using( item => item ); - - return groupedItemsDropdown; - } - - /** - * A method that updates the {@link #_focusCycleableItems focus–cycleable items} - * collection so it represents the up–to–date state of the UI from the perspective of the user. - * - * See the {@link #_focusCycleableItems collection} documentation to learn more about the purpose - * of this method. - * - * @private - */ - _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#_ungroupedItems ungrouped items}. + * {@link module:ui/toolbar/toolbarview~ToolbarView#ungroupedItems ungrouped items}. * * @private * @extends module:ui/view~View */ -class UngrouppedItemsView extends View { +class ItemsView extends View { /** * @inheritDoc */ @@ -487,19 +344,54 @@ class UngrouppedItemsView extends View { } } +class StaticToolbar { + constructor( view ) { + this.view = view; + + /** + * Controls the orientation of toolbar items. + * + * @observable + * @member {Boolean} #isVertical + */ + view.set( 'isVertical', false ); + + view._focusCycleableItems.bindTo( view.items ); + + // 1:1 pass–through binding. + view._itemsView.items.bindTo( view.items ).using( item => item ); + } + + extendTemplate() { + const bind = this.view.bindTemplate; + + this.view.extendTemplate( { + attributes: { + class: [ + bind.if( 'isVertical', 'ck-toolbar_vertical' ) + ] + } + } ); + } + + render() { + // Nothing to do here? + } +} + /** * A helper class that manages the presentation layer of the {@link module:ui/toolbar/toolbarview~ToolbarView}. * * In a nutshell, it distributes the toolbar {@link module:ui/toolbar/toolbarview~ToolbarView#items} - * among its {@link module:ui/toolbar/toolbarview~ToolbarView#_groupedItems} and - * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems} + * among its {@link module:ui/toolbar/toolbarview~ToolbarView#groupedItems} and + * {@link module:ui/toolbar/toolbarview~ToolbarView#ungroupedItems} * depending on the configuration of the toolbar, the geometry and the number of items. * * @private */ -class ToolbarItemsManager { +class DynamicGroupingToolbar { /** - * Creates an instance of the {@link module:ui/toolbar/toolbarview~ToolbarItemsManager} class. + * Creates an instance of the {@link module:ui/toolbar/toolbarview~DynamicGroupingToolbar} class. * * @param {Object} options The configuration of the helper. * @param {Boolean} options.shouldGroupWhenFull Corresponds to @@ -507,58 +399,64 @@ class ToolbarItemsManager { * @param {module:ui/viewcollection~ViewCollection} options.items Corresponds to * {@link module:ui/toolbar/toolbarview~ToolbarView#items}. * @param {module:ui/viewcollection~ViewCollection} options.ungroupedItems Corresponds to - * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems}. + * {@link module:ui/toolbar/toolbarview~ToolbarView#ungroupedItems}. * @param {module:ui/viewcollection~ViewCollection} options.groupedItems Corresponds to - * {@link module:ui/toolbar/toolbarview~ToolbarView#_groupedItems}/ + * {@link module:ui/toolbar/toolbarview~ToolbarView#groupedItems}/ * @param {HTMLElement} options.element Corresponds to {@link module:ui/toolbar/toolbarview~ToolbarView#element}. * @param {String} options.uiLanguageDirection Corresponds to {@link module:utils/locale~Locale#uiLanguageDirection}. * @param {Function} options.onGroupStart Executed when the first ungrouped toolbar item gets grouped. * @param {Function} options.onGroupEnd Executed when the last of the grouped toolbar items just got ungrouped. */ - constructor( options ) { - Object.assign( this, options ); - - /** - * @readonly - * @member {Boolean} #shouldGroupWhenFull - */ - - /** - * @readonly - * @member {module:ui/viewcollection~ViewCollection} #items - */ - - /** - * @readonly - * @member {module:ui/viewcollection~ViewCollection} #ungroupedItems - */ - - /** - * @readonly - * @member {module:ui/viewcollection~ViewCollection} #groupedItems - */ - - /** - * @readonly - * @member {HTMLElement} #element - */ + constructor( view ) { + this.view = view; /** + * A subset of of toolbar {@link #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 a {@link #_itemsView dedicated view}. + * + * When none of the {@link #items} were grouped, it matches the {@link #items} collection in size and order. + * + * **Note**: Grouping occurs only when the toolbar was + * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull configured}. + * + * See the {@link #_itemsManager} to learn more. + * + * @private * @readonly - * @member {String} #uiLanguageDirection + * @member {module:ui/viewcollection~ViewCollection} */ + this.ungroupedItems = view.createCollection(); /** + * A subset of of toolbar {@link #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 #items} were grouped, this collection is empty. + * + * **Note**: Grouping occurs only when the toolbar was + * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull configured}. + * + * See the {@link #_itemsManager} to learn more. + * + * @private * @readonly - * @member {Function} #onGroupStart Executed when the first ungrouped toolbar item gets grouped. - * Called by {@link #groupLastItem}. + * @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 at the end of the toolbar and offers another + * (nested) toolbar which displays items that would normally overflow. + * + * See the {@link #_itemsManager} to learn more. + * + * @private * @readonly - * @member {Function} #onGroupEnd Executed when the last of the grouped toolbar items just - * got ungrouped. Called by {@link #ungroupFirstItem}. + * @member {module:ui/dropdown/dropdownview~DropdownView} */ + this.groupedItemsDropdown = this._createGrouppedItemsDropdown(); /** * An instance of the resize observer that helps dynamically determine the geometry of the toolbar @@ -600,9 +498,20 @@ class ToolbarItemsManager { */ this._cachedPadding = null; + // 1:1 pass–through binding. + view._itemsView.items.bindTo( this.ungroupedItems ).using( item => item ); + + // Make sure all #items visible in the main space of the toolbar are cycleable. + 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._components.on( 'add', this._updateFocusCycleableItems.bind( this ) ); + view._components.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. - this.items.on( 'add', ( evt, item, index ) => { + view.items.on( 'add', ( evt, item, index ) => { if ( index > this.ungroupedItems.length ) { this.groupedItems.add( item, index - this.ungroupedItems.length ); } else { @@ -610,15 +519,13 @@ class ToolbarItemsManager { // When a new ungrouped item joins in, there's a chance it causes the toolbar to overflow. // Let's check this out and do the grouping if necessary. - if ( options.shouldGroupWhenFull ) { - this.updateGrouping(); - } + this.updateGrouping(); } } ); // When an item is removed from ToolbarView#items, it should be automatically // removed from either grouped or ungrouped items. - this.items.on( 'remove', ( evt, item ) => { + view.items.on( 'remove', ( evt, item ) => { if ( this.groupedItems.has( item ) ) { this.groupedItems.remove( item ); } else if ( this.ungroupedItems.has( item ) ) { @@ -627,14 +534,33 @@ class ToolbarItemsManager { // Whether removed from grouped or ungrouped items, there is a chance // some new space is available and we could do some ungrouping. - if ( options.shouldGroupWhenFull ) { - this.updateGrouping(); + this.updateGrouping(); + } ); + } + + render() { + this.enableGroupingOnResize(); + } + + extendTemplate() { + this.view.extendTemplate( { + attributes: { + class: [ + 'ck-toolbar_grouping' + ] } } ); + } - if ( options.shouldGroupWhenFull ) { - this.enableGroupingOnResize(); - } + /** + * Cleans up after the manager when its parent toolbar is destroyed. + */ + 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. + this.groupedItemsDropdown.destroy(); + + this._resizeObserver.disconnect(); } /** @@ -653,11 +579,13 @@ class ToolbarItemsManager { return; } + const view = this.view; + // 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 ( !view.element.ownerDocument.body.contains( view.element ) ) { return; } @@ -706,6 +634,8 @@ class ToolbarItemsManager { * on the visual space available. */ enableGroupingOnResize() { + const view = this.view; + let previousWidth; // TODO: Consider debounce. @@ -717,20 +647,11 @@ class ToolbarItemsManager { } } ); - this._resizeObserver.observe( this.element ); + this._resizeObserver.observe( view.element ); this.updateGrouping(); } - /** - * Cleans up after the manager when its parent toolbar is destroyed. - */ - destroy() { - if ( this._resizeObserver ) { - this._resizeObserver.disconnect(); - } - } - /** * Returns `true` when {@link #element} children visually overflow, for instance if the * toolbar is narrower than its members. `false` otherwise. @@ -743,12 +664,14 @@ class ToolbarItemsManager { return false; } - const uiLanguageDirection = this.uiLanguageDirection; - const lastChildRect = new Rect( this.element.lastChild ); - const toolbarRect = new Rect( this.element ); + const view = this.view; + const element = view.element; + const uiLanguageDirection = view.locale.uiLanguageDirection; + const lastChildRect = new Rect( element.lastChild ); + const toolbarRect = new Rect( element ); if ( !this._cachedPadding ) { - const computedStyle = global.window.getComputedStyle( this.element ); + const computedStyle = global.window.getComputedStyle( element ); const paddingProperty = uiLanguageDirection === 'ltr' ? 'paddingRight' : 'paddingLeft'; // parseInt() is essential because of quirky floating point numbers logic and DOM. @@ -773,8 +696,12 @@ class ToolbarItemsManager { * @protected */ groupLastItem() { + const view = this.view; + if ( !this.groupedItems.length ) { - this.onGroupStart(); + view._components.add( new ToolbarSeparatorView() ); + view._components.add( this.groupedItemsDropdown ); + view.focusTracker.add( this.groupedItemsDropdown.element ); } this.groupedItems.add( this.ungroupedItems.remove( this.ungroupedItems.last ), 0 ); @@ -789,10 +716,65 @@ class ToolbarItemsManager { * @protected */ ungroupFirstItem() { + const view = this.view; + this.ungroupedItems.add( this.groupedItems.remove( this.groupedItems.first ) ); if ( !this.groupedItems.length ) { - this.onGroupEnd(); + view._components.remove( this.groupedItemsDropdown ); + view._components.remove( view._components.last ); + view.focusTracker.remove( this.groupedItemsDropdown.element ); + } + } + + /** + * Creates the {@link #groupedItemsDropdown} that hosts the members of the {@link #groupedItems} + * collection when there is not enough space in the toolbar to display all items in a single row. + * + * @private + * @returns {module:ui/dropdown/dropdownview~DropdownView} + */ + _createGrouppedItemsDropdown() { + const view = this.view; + const t = view.t; + const locale = view.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 + } ); + + // 1:1 pass–through binding. + groupedItemsDropdown.toolbarView.items.bindTo( this.groupedItems ).using( item => item ); + + return groupedItemsDropdown; + } + + /** + * A method that updates the {@link #_focusCycleableItems focus–cycleable items} + * collection so it represents the up–to–date state of the UI from the perspective of the user. + * + * See the {@link #_focusCycleableItems collection} documentation to learn more about the purpose + * of this method. + * + * @private + */ + _updateFocusCycleableItems() { + const view = this.view; + + view._focusCycleableItems.clear(); + + this.ungroupedItems.map( item => { + view._focusCycleableItems.add( item ); + } ); + + if ( this.groupedItems.length ) { + view._focusCycleableItems.add( this.groupedItemsDropdown ); } } } From 741197441efc821d1bec6c0f164dcd4304522af8 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Thu, 10 Oct 2019 13:40:35 +0200 Subject: [PATCH 27/39] Code refactoring. --- src/toolbar/toolbarview.js | 184 +++++++++++++++++++------------------ 1 file changed, 94 insertions(+), 90 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index d10b1274..0cc29844 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -95,8 +95,8 @@ export default class ToolbarView extends View { this.set( 'class' ); /** - * A view containing {@link #ungroupedItems ungrouped toolbar items} (as opposed to the - * {@link #groupedItemsDropdown} containing {@link #groupedItems grouped toolbar items}). + * A view containing {@link #_ungroupedItems ungrouped toolbar items} (as opposed to the + * {@link #_groupedItemsDropdown} containing {@link #_groupedItems grouped toolbar items}). * * See the {@link #_itemsManager} to learn more. * @@ -108,15 +108,15 @@ export default class ToolbarView extends View { /** * A top–level collection aggregating building blocks of the toolbar. It mainly exists to - * make sure {@link #ungroupedItems} do not mix up with the {@link #groupedItemsDropdown}. + * make sure {@link #_ungroupedItems} do not mix up with the {@link #_groupedItemsDropdown}. * * It helps a lot when the {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull grouping} * logic is on (no re–ordering issues, exclusions, etc.). * * ┌───────────────────────────────────────── ToolbarView ──────────────────────────────────────────┐ * | ┌─────────────────────────────────────── #_components ───────────────────────────────────────┐ | - * | | ┌────── #_itemsView ────────┐ ┌──────────────────────┐ ┌── #groupedItemsDropdown ───┐ | | - * | | | #ungroupedItems | | ToolbarSeparatorView | | #groupedItems | | | + * | | ┌────── #_itemsView ────────┐ ┌──────────────────────┐ ┌── #_groupedItemsDropdown ───┐ | | + * | | | #_ungroupedItems | | ToolbarSeparatorView | | #_groupedItems | | | * | | └──────────────────────────-┘ └──────────────────────┘ └─────────────────────────────┘ | | * | | \--------- only when toolbar items overflow ---------/ | | * | └────────────────────────────────────────────────────────────────────────────────────────────┘ | @@ -133,15 +133,15 @@ export default class ToolbarView extends View { * A helper collection that aggregates a subset of {@link #items} that is subject to the focus cycling * (e.g. navigation using the keyboard). * - * It contains all the items from {@link #ungroupedItems} plus (optionally) the {@link #groupedItemsDropdown} + * It contains all the items from {@link #_ungroupedItems} plus (optionally) the {@link #_groupedItemsDropdown} * at the end. * - * This collection is dynamic and responds to the changes in {@link #ungroupedItems} and {@link #_components} + * This collection is dynamic and responds to the changes in {@link #_ungroupedItems} and {@link #_components} * so the {@link #_focusCycler focus cycler} logic operates on the up–to–date collection of items that * are actually available for the user to focus and navigate at this particular moment. * * This collection is necessary because the {@link #_itemsManager} can dynamically change the content - * of the {@link #ungroupedItems} and also spontaneously display the {@link #groupedItemsDropdown} + * of the {@link #_ungroupedItems} and also spontaneously display the {@link #_groupedItemsDropdown} * (also focusable and "cycleable"). * * @private @@ -173,8 +173,8 @@ export default class ToolbarView extends View { /** * An instance of the utility responsible for managing the toolbar {@link #items}. * - * For instance, it controls which of the {@link #items} should be {@link #ungroupedItems ungrouped} or - * {@link #groupedItems grouped} depending on the configuration of the toolbar and its geometry. + * For instance, it controls which of the {@link #items} should be {@link #_ungroupedItems ungrouped} or + * {@link #_groupedItems grouped} depending on the configuration of the toolbar and its geometry. * * **Note**: The instance is created upon {@link #render} when the {@link #element} of the toolbar * starts to exist. @@ -297,7 +297,7 @@ export default class ToolbarView extends View { } /** - * Creates the {@link #_itemsView} that hosts the members of the {@link #ungroupedItems} collection. + * Creates the {@link #_itemsView} that hosts the members of the {@link #_ungroupedItems} collection. * * @private * @returns {module:ui/toolbar/toolbarview~ItemsView} @@ -311,7 +311,7 @@ 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#ungroupedItems ungrouped items}. + * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems ungrouped items}. * * @private * @extends module:ui/view~View @@ -377,14 +377,18 @@ class StaticToolbar { render() { // Nothing to do here? } + + destroy() { + // Nothing to do here? + } } /** * A helper class that manages the presentation layer of the {@link module:ui/toolbar/toolbarview~ToolbarView}. * * In a nutshell, it distributes the toolbar {@link module:ui/toolbar/toolbarview~ToolbarView#items} - * among its {@link module:ui/toolbar/toolbarview~ToolbarView#groupedItems} and - * {@link module:ui/toolbar/toolbarview~ToolbarView#ungroupedItems} + * among its {@link module:ui/toolbar/toolbarview~ToolbarView#_groupedItems} and + * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems} * depending on the configuration of the toolbar, the geometry and the number of items. * * @private @@ -398,10 +402,10 @@ class DynamicGroupingToolbar { * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull}. * @param {module:ui/viewcollection~ViewCollection} options.items Corresponds to * {@link module:ui/toolbar/toolbarview~ToolbarView#items}. - * @param {module:ui/viewcollection~ViewCollection} options.ungroupedItems Corresponds to - * {@link module:ui/toolbar/toolbarview~ToolbarView#ungroupedItems}. - * @param {module:ui/viewcollection~ViewCollection} options.groupedItems Corresponds to - * {@link module:ui/toolbar/toolbarview~ToolbarView#groupedItems}/ + * @param {module:ui/viewcollection~ViewCollection} options._ungroupedItems Corresponds to + * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems}. + * @param {module:ui/viewcollection~ViewCollection} options._groupedItems Corresponds to + * {@link module:ui/toolbar/toolbarview~ToolbarView#_groupedItems}/ * @param {HTMLElement} options.element Corresponds to {@link module:ui/toolbar/toolbarview~ToolbarView#element}. * @param {String} options.uiLanguageDirection Corresponds to {@link module:utils/locale~Locale#uiLanguageDirection}. * @param {Function} options.onGroupStart Executed when the first ungrouped toolbar item gets grouped. @@ -412,7 +416,7 @@ class DynamicGroupingToolbar { /** * A subset of of toolbar {@link #items}. Aggregates items that fit into a single row of the toolbar - * and were not {@link #groupedItems grouped} into a {@link #groupedItemsDropdown dropdown}. + * and were not {@link #_groupedItems grouped} into a {@link #_groupedItemsDropdown dropdown}. * Items of this collection are displayed in a {@link #_itemsView dedicated view}. * * When none of the {@link #items} were grouped, it matches the {@link #items} collection in size and order. @@ -426,11 +430,11 @@ class DynamicGroupingToolbar { * @readonly * @member {module:ui/viewcollection~ViewCollection} */ - this.ungroupedItems = view.createCollection(); + this._ungroupedItems = view.createCollection(); /** * A subset of of toolbar {@link #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}. + * single row of the toolbar. Grouped items are displayed in a dedicated {@link #_groupedItemsDropdown dropdown}. * * When none of the {@link #items} were grouped, this collection is empty. * @@ -443,10 +447,10 @@ class DynamicGroupingToolbar { * @readonly * @member {module:ui/viewcollection~ViewCollection} */ - this.groupedItems = view.createCollection(); + this._groupedItems = view.createCollection(); /** - * The dropdown that aggregates {@link #groupedItems grouped items} that do not fit into a single + * The dropdown that aggregates {@link #_groupedItems grouped items} that do not fit into a single * row of the toolbar. It is displayed on demand at the end of the toolbar and offers another * (nested) toolbar which displays items that would normally overflow. * @@ -456,13 +460,13 @@ class DynamicGroupingToolbar { * @readonly * @member {module:ui/dropdown/dropdownview~DropdownView} */ - this.groupedItemsDropdown = this._createGrouppedItemsDropdown(); + this._groupedItemsDropdown = this._createGrouppedItemsDropdown(); /** * 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 in {@link #enableGroupingOnResize}. + * **Note:** Created dynamically in {@link #_enableGroupingOnResize}. * * @readonly * @private @@ -471,12 +475,12 @@ class DynamicGroupingToolbar { this._resizeObserver = null; /** - * A flag used by {@link #updateGrouping} method to make sure no concurrent updates - * are performed to the {@link #ungroupedItems} and {@link #groupedItems}. Because {@link #updateGrouping} + * A flag used by {@link #_updateGrouping} method to make sure no concurrent updates + * are performed to the {@link #_ungroupedItems} and {@link #_groupedItems}. Because {@link #_updateGrouping} * manages those collections but also is executed upon changes in those collections, this flag * ensures no infinite loops occur. * - * **Note:** Used only if {@link #enableGroupingOnResize} was called. + * **Note:** Used only if {@link #_enableGroupingOnResize} was called. * * @readonly * @private @@ -485,12 +489,12 @@ class DynamicGroupingToolbar { this._updateLock = false; /** - * A cached value of the horizontal padding style used by {@link #updateGrouping} + * A cached value of the horizontal padding style used by {@link #_updateGrouping} * 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:** In use only after {@link #enableGroupingOnResize} was called. + * **Note:** In use only after {@link #_enableGroupingOnResize} was called. * * @readonly * @private @@ -499,47 +503,47 @@ class DynamicGroupingToolbar { this._cachedPadding = null; // 1:1 pass–through binding. - view._itemsView.items.bindTo( this.ungroupedItems ).using( item => item ); + view._itemsView.items.bindTo( this._ungroupedItems ).using( item => item ); // Make sure all #items visible in the main space of the toolbar are cycleable. - this.ungroupedItems.on( 'add', this._updateFocusCycleableItems.bind( this ) ); - this.ungroupedItems.on( 'remove', this._updateFocusCycleableItems.bind( this ) ); + 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. + // Make sure the #_groupedItemsDropdown is also included in cycling when it appears. view._components.on( 'add', this._updateFocusCycleableItems.bind( this ) ); view._components.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. view.items.on( 'add', ( evt, item, index ) => { - if ( index > this.ungroupedItems.length ) { - this.groupedItems.add( item, index - this.ungroupedItems.length ); + if ( index > this._ungroupedItems.length ) { + this._groupedItems.add( item, index - this._ungroupedItems.length ); } else { - this.ungroupedItems.add( item, index ); + this._ungroupedItems.add( item, index ); // When a new ungrouped item joins in, there's a chance it causes the toolbar to overflow. // Let's check this out and do the grouping if necessary. - this.updateGrouping(); + 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 ) => { - if ( this.groupedItems.has( item ) ) { - this.groupedItems.remove( item ); - } else if ( this.ungroupedItems.has( item ) ) { - this.ungroupedItems.remove( item ); + if ( this._groupedItems.has( item ) ) { + this._groupedItems.remove( item ); + } else if ( this._ungroupedItems.has( item ) ) { + 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(); + this._updateGrouping(); } ); } render() { - this.enableGroupingOnResize(); + this._enableGroupingOnResize(); } extendTemplate() { @@ -558,20 +562,20 @@ class DynamicGroupingToolbar { 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. - this.groupedItemsDropdown.destroy(); + 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. + * 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 + * {@link #_groupedItems} to be returned back to {@link #_ungroupedItems} and still fit into a single row * without the toolbar wrapping. */ - updateGrouping() { + _updateGrouping() { // 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. @@ -596,8 +600,8 @@ class DynamicGroupingToolbar { // 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(); + while ( this._areItemsOverflowing ) { + this._groupLastItem(); wereItemsGrouped = true; } @@ -605,18 +609,18 @@ class DynamicGroupingToolbar { // 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 ) { + 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(); + 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(); + if ( this._areItemsOverflowing ) { + this._groupLastItem(); } } @@ -624,16 +628,16 @@ class DynamicGroupingToolbar { } /** - * Enables the functionality that prevents {@link #ungroupedItems} from overflowing + * 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. + * {@link #_groupedItems} collection and displayed in a dropdown at the end of the space, which has its own nested toolbar. * - * When called, the toolbar will automatically analyze the location of its {@link #ungroupedItems} and "group" + * 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. */ - enableGroupingOnResize() { + _enableGroupingOnResize() { const view = this.view; let previousWidth; @@ -641,7 +645,7 @@ class DynamicGroupingToolbar { // TODO: Consider debounce. this._resizeObserver = getResizeObserver( ( [ entry ] ) => { if ( !previousWidth || previousWidth !== entry.contentRect.width ) { - this.updateGrouping(); + this._updateGrouping(); previousWidth = entry.contentRect.width; } @@ -649,7 +653,7 @@ class DynamicGroupingToolbar { this._resizeObserver.observe( view.element ); - this.updateGrouping(); + this._updateGrouping(); } /** @@ -658,9 +662,9 @@ class DynamicGroupingToolbar { * * @type {Boolean} */ - get areItemsOverflowing() { + get _areItemsOverflowing() { // An empty toolbar cannot overflow. - if ( !this.ungroupedItems.length ) { + if ( !this._ungroupedItems.length ) { return false; } @@ -688,47 +692,47 @@ class DynamicGroupingToolbar { } /** - * The opposite of {@link #ungroupFirstItem}. + * The opposite of {@link #_ungroupFirstItem}. * - * When called it will remove the last item from {@link #ungroupedItems} and move it to the - * {@link #groupedItems} collection. + * When called it will remove the last item from {@link #_ungroupedItems} and move it to the + * {@link #_groupedItems} collection. * * @protected */ - groupLastItem() { + _groupLastItem() { const view = this.view; - if ( !this.groupedItems.length ) { + if ( !this._groupedItems.length ) { view._components.add( new ToolbarSeparatorView() ); - view._components.add( this.groupedItemsDropdown ); - view.focusTracker.add( this.groupedItemsDropdown.element ); + view._components.add( this._groupedItemsDropdown ); + view.focusTracker.add( this._groupedItemsDropdown.element ); } - this.groupedItems.add( this.ungroupedItems.remove( this.ungroupedItems.last ), 0 ); + this._groupedItems.add( this._ungroupedItems.remove( this._ungroupedItems.last ), 0 ); } /** - * The opposite of {@link #groupLastItem}. + * The opposite of {@link #_groupLastItem}. * - * Moves the very first item from the toolbar belonging to {@link #groupedItems} back - * to the {@link #ungroupedItems} collection. + * Moves the very first item from the toolbar belonging to {@link #_groupedItems} back + * to the {@link #_ungroupedItems} collection. * * @protected */ - ungroupFirstItem() { + _ungroupFirstItem() { const view = this.view; - this.ungroupedItems.add( this.groupedItems.remove( this.groupedItems.first ) ); + this._ungroupedItems.add( this._groupedItems.remove( this._groupedItems.first ) ); - if ( !this.groupedItems.length ) { - view._components.remove( this.groupedItemsDropdown ); + if ( !this._groupedItems.length ) { + view._components.remove( this._groupedItemsDropdown ); view._components.remove( view._components.last ); - view.focusTracker.remove( this.groupedItemsDropdown.element ); + view.focusTracker.remove( this._groupedItemsDropdown.element ); } } /** - * Creates the {@link #groupedItemsDropdown} that hosts the members of the {@link #groupedItems} + * Creates the {@link #_groupedItemsDropdown} that hosts the members of the {@link #_groupedItems} * collection when there is not enough space in the toolbar to display all items in a single row. * * @private @@ -738,21 +742,21 @@ class DynamicGroupingToolbar { const view = this.view; const t = view.t; const locale = view.locale; - const groupedItemsDropdown = createDropdown( locale ); + const _groupedItemsDropdown = createDropdown( locale ); - groupedItemsDropdown.class = 'ck-toolbar__grouped-dropdown'; - addToolbarToDropdown( groupedItemsDropdown, [] ); + _groupedItemsDropdown.class = 'ck-toolbar__grouped-dropdown'; + addToolbarToDropdown( _groupedItemsDropdown, [] ); - groupedItemsDropdown.buttonView.set( { + _groupedItemsDropdown.buttonView.set( { label: t( 'Show more items' ), tooltip: true, icon: verticalDotsIcon } ); // 1:1 pass–through binding. - groupedItemsDropdown.toolbarView.items.bindTo( this.groupedItems ).using( item => item ); + _groupedItemsDropdown.toolbarView.items.bindTo( this._groupedItems ).using( item => item ); - return groupedItemsDropdown; + return _groupedItemsDropdown; } /** @@ -769,12 +773,12 @@ class DynamicGroupingToolbar { view._focusCycleableItems.clear(); - this.ungroupedItems.map( item => { + this._ungroupedItems.map( item => { view._focusCycleableItems.add( item ); } ); - if ( this.groupedItems.length ) { - view._focusCycleableItems.add( this.groupedItemsDropdown ); + if ( this._groupedItems.length ) { + view._focusCycleableItems.add( this._groupedItemsDropdown ); } } } From fafcbb27f6fe266812a80342e1e8f4053b4fbb36 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Thu, 10 Oct 2019 16:40:16 +0200 Subject: [PATCH 28/39] Reverted changes made to ToolbarView#fillFromConfig. --- src/toolbar/toolbarview.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 0cc29844..d4574652 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -267,11 +267,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 @@ -515,6 +515,8 @@ class DynamicGroupingToolbar { // 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 ); From 5423147c45ef053a18343711b37ce3de8fd0f1ca Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Thu, 10 Oct 2019 17:00:13 +0200 Subject: [PATCH 29/39] Code refactoring. --- src/toolbar/toolbarview.js | 124 +++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 67 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index d4574652..8697bb15 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -114,7 +114,7 @@ export default class ToolbarView extends View { * logic is on (no re–ordering issues, exclusions, etc.). * * ┌───────────────────────────────────────── ToolbarView ──────────────────────────────────────────┐ - * | ┌─────────────────────────────────────── #_components ───────────────────────────────────────┐ | + * | ┌─────────────────────────────────────── #components ───────────────────────────────────────┐ | * | | ┌────── #_itemsView ────────┐ ┌──────────────────────┐ ┌── #_groupedItemsDropdown ───┐ | | * | | | #_ungroupedItems | | ToolbarSeparatorView | | #_groupedItems | | | * | | └──────────────────────────-┘ └──────────────────────┘ └─────────────────────────────┘ | | @@ -123,11 +123,10 @@ export default class ToolbarView extends View { * └────────────────────────────────────────────────────────────────────────────────────────────────┘ * * @readonly - * @protected * @member {module:ui/viewcollection~ViewCollection} */ - this._components = this.createCollection(); - this._components.add( this._itemsView ); + this.components = this.createCollection(); + this.components.add( this._itemsView ); /** * A helper collection that aggregates a subset of {@link #items} that is subject to the focus cycling @@ -136,7 +135,7 @@ export default class ToolbarView extends View { * It contains all the items from {@link #_ungroupedItems} plus (optionally) the {@link #_groupedItemsDropdown} * at the end. * - * This collection is dynamic and responds to the changes in {@link #_ungroupedItems} and {@link #_components} + * This collection is dynamic and responds to the changes in {@link #_ungroupedItems} and {@link #components} * so the {@link #_focusCycler focus cycler} logic operates on the up–to–date collection of items that * are actually available for the user to focus and navigate at this particular moment. * @@ -148,7 +147,7 @@ export default class ToolbarView extends View { * @readonly * @member {module:ui/viewcollection~ViewCollection} */ - this._focusCycleableItems = this.createCollection(); + this.focusables = this.createCollection(); /** * Helps cycling over focusable {@link #items} in the toolbar. @@ -158,7 +157,7 @@ export default class ToolbarView extends View { * @member {module:ui/focuscycler~FocusCycler} */ this._focusCycler = new FocusCycler( { - focusables: this._focusCycleableItems, + focusables: this.focusables, focusTracker: this.focusTracker, keystrokeHandler: this.keystrokes, actions: { @@ -170,21 +169,6 @@ export default class ToolbarView extends View { } } ); - /** - * An instance of the utility responsible for managing the toolbar {@link #items}. - * - * For instance, it controls which of the {@link #items} should be {@link #_ungroupedItems ungrouped} or - * {@link #_groupedItems grouped} depending on the configuration of the toolbar and its geometry. - * - * **Note**: The instance is created upon {@link #render} when the {@link #element} of the toolbar - * starts to exist. - * - * @private - * @readonly - * @member {module:ui/toolbar/toolbarview~DynamicGroupingToolbar} - */ - this._kind = this.options.shouldGroupWhenFull ? new DynamicGroupingToolbar( this ) : new StaticToolbar( this ); - this.setTemplate( { tag: 'div', attributes: { @@ -197,7 +181,7 @@ export default class ToolbarView extends View { 'aria-label': bind.to( 'ariaLabel' ) }, - children: this._components, + children: this.components, on: { // https://github.com/ckeditor/ckeditor5-ui/issues/206 @@ -205,7 +189,20 @@ export default class ToolbarView extends View { } } ); - this._kind.extendTemplate(); + /** + * An instance of the utility responsible for managing the toolbar {@link #items}. + * + * For instance, it controls which of the {@link #items} should be {@link #_ungroupedItems ungrouped} or + * {@link #_groupedItems grouped} depending on the configuration of the toolbar and its geometry. + * + * **Note**: The instance is created upon {@link #render} when the {@link #element} of the toolbar + * starts to exist. + * + * @private + * @readonly + * @member {module:ui/toolbar/toolbarview~DynamicGrouping} + */ + this._extension = this.options.shouldGroupWhenFull ? new DynamicGrouping( this ) : new VerticalLayout( this ); } /** @@ -230,14 +227,14 @@ export default class ToolbarView extends View { // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); - this._kind.render(); + this._extension.render(); } /** * @inheritDoc */ destroy() { - this._kind.destroy(); + this._extension.destroy(); return super.destroy(); } @@ -344,10 +341,12 @@ class ItemsView extends View { } } -class StaticToolbar { +class VerticalLayout { constructor( view ) { this.view = view; + const bind = this.view.bindTemplate; + /** * Controls the orientation of toolbar items. * @@ -356,16 +355,13 @@ class StaticToolbar { */ view.set( 'isVertical', false ); - view._focusCycleableItems.bindTo( view.items ); + // 1:1 pass–through binding. + view.focusables.bindTo( view.items ).using( item => item ); // 1:1 pass–through binding. view._itemsView.items.bindTo( view.items ).using( item => item ); - } - extendTemplate() { - const bind = this.view.bindTemplate; - - this.view.extendTemplate( { + view.extendTemplate( { attributes: { class: [ bind.if( 'isVertical', 'ck-toolbar_vertical' ) @@ -374,13 +370,9 @@ class StaticToolbar { } ); } - render() { - // Nothing to do here? - } + render() {} - destroy() { - // Nothing to do here? - } + destroy() {} } /** @@ -393,9 +385,9 @@ class StaticToolbar { * * @private */ -class DynamicGroupingToolbar { +class DynamicGrouping { /** - * Creates an instance of the {@link module:ui/toolbar/toolbarview~DynamicGroupingToolbar} class. + * Creates an instance of the {@link module:ui/toolbar/toolbarview~DynamicGrouping} class. * * @param {Object} options The configuration of the helper. * @param {Boolean} options.shouldGroupWhenFull Corresponds to @@ -510,8 +502,8 @@ class DynamicGroupingToolbar { this._ungroupedItems.on( 'remove', this._updateFocusCycleableItems.bind( this ) ); // Make sure the #_groupedItemsDropdown is also included in cycling when it appears. - view._components.on( 'add', this._updateFocusCycleableItems.bind( this ) ); - view._components.on( 'remove', this._updateFocusCycleableItems.bind( this ) ); + view.components.on( 'add', this._updateFocusCycleableItems.bind( this ) ); + view.components.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. @@ -522,19 +514,19 @@ class DynamicGroupingToolbar { this._groupedItems.add( item, index - this._ungroupedItems.length ); } else { this._ungroupedItems.add( item, index ); - - // When a new ungrouped item joins in, there's a chance it causes the toolbar to overflow. - // Let's check this out and do the grouping if necessary. - this._updateGrouping(); } + + // 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 ) => { - if ( this._groupedItems.has( item ) ) { + view.items.on( 'remove', ( evt, item, index ) => { + if ( index > this._ungroupedItems.length ) { this._groupedItems.remove( item ); - } else if ( this._ungroupedItems.has( item ) ) { + } else { this._ungroupedItems.remove( item ); } @@ -542,14 +534,8 @@ class DynamicGroupingToolbar { // some new space is available and we could do some ungrouping. this._updateGrouping(); } ); - } - - render() { - this._enableGroupingOnResize(); - } - extendTemplate() { - this.view.extendTemplate( { + view.extendTemplate( { attributes: { class: [ 'ck-toolbar_grouping' @@ -558,11 +544,15 @@ class DynamicGroupingToolbar { } ); } + render() { + this._enableGroupingOnResize(); + } + /** * Cleans up after the manager when its parent toolbar is destroyed. */ destroy() { - // The dropdown may not be in #_components at the moment of toolbar destruction + // 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. this._groupedItemsDropdown.destroy(); @@ -705,8 +695,8 @@ class DynamicGroupingToolbar { const view = this.view; if ( !this._groupedItems.length ) { - view._components.add( new ToolbarSeparatorView() ); - view._components.add( this._groupedItemsDropdown ); + view.components.add( new ToolbarSeparatorView() ); + view.components.add( this._groupedItemsDropdown ); view.focusTracker.add( this._groupedItemsDropdown.element ); } @@ -727,8 +717,8 @@ class DynamicGroupingToolbar { this._ungroupedItems.add( this._groupedItems.remove( this._groupedItems.first ) ); if ( !this._groupedItems.length ) { - view._components.remove( this._groupedItemsDropdown ); - view._components.remove( view._components.last ); + view.components.remove( this._groupedItemsDropdown ); + view.components.remove( view.components.last ); view.focusTracker.remove( this._groupedItemsDropdown.element ); } } @@ -762,10 +752,10 @@ class DynamicGroupingToolbar { } /** - * A method that updates the {@link #_focusCycleableItems focus–cycleable items} + * A method that updates the {@link #focusables focus–cycleable items} * collection so it represents the up–to–date state of the UI from the perspective of the user. * - * See the {@link #_focusCycleableItems collection} documentation to learn more about the purpose + * See the {@link #focusables collection} documentation to learn more about the purpose * of this method. * * @private @@ -773,14 +763,14 @@ class DynamicGroupingToolbar { _updateFocusCycleableItems() { const view = this.view; - view._focusCycleableItems.clear(); + view.focusables.clear(); this._ungroupedItems.map( item => { - view._focusCycleableItems.add( item ); + view.focusables.add( item ); } ); if ( this._groupedItems.length ) { - view._focusCycleableItems.add( this._groupedItemsDropdown ); + view.focusables.add( this._groupedItemsDropdown ); } } } From 647fc1fcd45996a28163882a345705036b7f9ff1 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 14 Oct 2019 11:33:03 +0200 Subject: [PATCH 30/39] Docs. --- src/toolbar/toolbarview.js | 311 ++++++++++++++++++++----------------- 1 file changed, 167 insertions(+), 144 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 8697bb15..4b6e1d21 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -95,62 +95,55 @@ export default class ToolbarView extends View { this.set( 'class' ); /** - * A view containing {@link #_ungroupedItems ungrouped toolbar items} (as opposed to the - * {@link #_groupedItemsDropdown} containing {@link #_groupedItems grouped toolbar items}). + * A (child) view containing {@link #items toolbar items}. * - * See the {@link #_itemsManager} to learn more. - * - * @private * @readonly * @member {module:ui/toolbar/toolbarview~ItemsView} */ - this._itemsView = this._createItemsView(); + this.itemsView = new ItemsView( locale ); /** - * A top–level collection aggregating building blocks of the toolbar. It mainly exists to - * make sure {@link #_ungroupedItems} do not mix up with the {@link #_groupedItemsDropdown}. + * A top–level collection aggregating building blocks of the toolbar. * - * It helps a lot when the {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull grouping} - * logic is on (no re–ordering issues, exclusions, etc.). + * ┌───────────────── ToolbarView ─────────────────┐ + * | ┌──────────────── #children ────────────────┐ | + * | | ┌──────────── #itemsView ───────────┐ | | + * | | | [ item1 ] [ item2 ] ... [ itemN ] | | | + * | | └──────────────────────────────────-┘ | | + * | └───────────────────────────────────────────┘ | + * └───────────────────────────────────────────────┘ * - * ┌───────────────────────────────────────── ToolbarView ──────────────────────────────────────────┐ - * | ┌─────────────────────────────────────── #components ───────────────────────────────────────┐ | - * | | ┌────── #_itemsView ────────┐ ┌──────────────────────┐ ┌── #_groupedItemsDropdown ───┐ | | - * | | | #_ungroupedItems | | ToolbarSeparatorView | | #_groupedItems | | | - * | | └──────────────────────────-┘ └──────────────────────┘ └─────────────────────────────┘ | | - * | | \--------- only when toolbar items overflow ---------/ | | - * | └────────────────────────────────────────────────────────────────────────────────────────────┘ | - * └────────────────────────────────────────────────────────────────────────────────────────────────┘ + * By default, it contains the {@link #itemsView} but it can be extended with additional + * UI elements when necessary. * * @readonly * @member {module:ui/viewcollection~ViewCollection} */ - this.components = this.createCollection(); - this.components.add( this._itemsView ); + this.children = this.createCollection(); + this.children.add( this.itemsView ); /** - * A helper collection that aggregates a subset of {@link #items} that is subject to the focus cycling - * (e.g. navigation using the keyboard). - * - * It contains all the items from {@link #_ungroupedItems} plus (optionally) the {@link #_groupedItemsDropdown} - * at the end. + * 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. * - * This collection is dynamic and responds to the changes in {@link #_ungroupedItems} and {@link #components} - * so the {@link #_focusCycler focus cycler} logic operates on the up–to–date collection of items that - * are actually available for the user to focus and navigate at this particular moment. - * - * This collection is necessary because the {@link #_itemsManager} can dynamically change the content - * of the {@link #_ungroupedItems} and also spontaneously display the {@link #_groupedItemsDropdown} - * (also focusable and "cycleable"). - * - * @private * @readonly * @member {module:ui/viewcollection~ViewCollection} */ this.focusables = this.createCollection(); /** - * Helps cycling over focusable {@link #items} in the toolbar. + * Controls the orientation of toolbar items. Only available when + * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull dynamic items grouping} + * is **disabled**. + * + * @observable + * @member {Boolean} #isVertical + */ + + /** + * Helps cycling over {@link #focusables focusable items} in the toolbar. * * @readonly * @protected @@ -181,7 +174,7 @@ export default class ToolbarView extends View { 'aria-label': bind.to( 'ariaLabel' ) }, - children: this.components, + children: this.children, on: { // https://github.com/ckeditor/ckeditor5-ui/issues/206 @@ -190,19 +183,15 @@ export default class ToolbarView extends View { } ); /** - * An instance of the utility responsible for managing the toolbar {@link #items}. - * - * For instance, it controls which of the {@link #items} should be {@link #_ungroupedItems ungrouped} or - * {@link #_groupedItems grouped} depending on the configuration of the toolbar and its geometry. + * An instance of the active toolbar feature that shapes its look and behavior. * - * **Note**: The instance is created upon {@link #render} when the {@link #element} of the toolbar - * starts to exist. + * See {@link module:ui/toolbar/toolbarview~ToolbarFeature} to learn more. * * @private * @readonly - * @member {module:ui/toolbar/toolbarview~DynamicGrouping} + * @member {module:ui/toolbar/toolbarview~ToolbarFeature} */ - this._extension = this.options.shouldGroupWhenFull ? new DynamicGrouping( this ) : new VerticalLayout( this ); + this._feature = this.options.shouldGroupWhenFull ? new DynamicGrouping( this ) : new StaticLayout( this ); } /** @@ -211,7 +200,7 @@ export default class ToolbarView extends View { render() { super.render(); - // Components added before rendering should be known to the #focusTracker. + // children added before rendering should be known to the #focusTracker. for ( const item of this.items ) { this.focusTracker.add( item.element ); } @@ -227,27 +216,27 @@ export default class ToolbarView extends View { // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); - this._extension.render(); + this._feature.render(); } /** * @inheritDoc */ destroy() { - this._extension.destroy(); + this._feature.destroy(); return super.destroy(); } /** - * Focuses the first focusable in {@link #items}. + * Focuses the first focusable in {@link #focusables}. */ focus() { this._focusCycler.focusFirst(); } /** - * Focuses the last focusable in {@link #items}. + * Focuses the last focusable in {@link #focusables}. */ focusLast() { this._focusCycler.focusLast(); @@ -292,23 +281,11 @@ export default class ToolbarView extends View { } } ); } - - /** - * Creates the {@link #_itemsView} that hosts the members of the {@link #_ungroupedItems} collection. - * - * @private - * @returns {module:ui/toolbar/toolbarview~ItemsView} - */ - _createItemsView() { - const ungrouppedItemsView = new ItemsView( this.locale ); - - return ungrouppedItemsView; - } } /** * An inner block of the {@link module:ui/toolbar/toolbarview~ToolbarView} hosting its - * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems ungrouped items}. + * {@link module:ui/toolbar/toolbarview~ToolbarView#items}. * * @private * @extends module:ui/view~View @@ -326,7 +303,7 @@ class ItemsView extends View { * @readonly * @member {module:ui/viewcollection~ViewCollection} */ - this.items = this.createCollection(); + this.children = this.createCollection(); this.setTemplate( { tag: 'div', @@ -336,87 +313,91 @@ class ItemsView extends View { 'ck-toolbar__items' ], }, - children: this.items + children: this.children } ); } } -class VerticalLayout { +/** + * A toolbar feature 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~ToolbarFeature + */ +class StaticLayout { + /** + * @inheritDoc + */ constructor( view ) { this.view = view; const bind = this.view.bindTemplate; - /** - * Controls the orientation of toolbar items. - * - * @observable - * @member {Boolean} #isVertical - */ + // Static toolbar can be vertical when needed. view.set( 'isVertical', false ); - // 1:1 pass–through binding. - view.focusables.bindTo( view.items ).using( item => item ); + // 1:1 pass–through binding, all ToolbarView#items are visible. + view.itemsView.children.bindTo( view.items ).using( item => item ); - // 1:1 pass–through binding. - view._itemsView.items.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 helper class that manages the presentation layer of the {@link module:ui/toolbar/toolbarview~ToolbarView}. + * A toolbar feature 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. * - * In a nutshell, it distributes the toolbar {@link module:ui/toolbar/toolbarview~ToolbarView#items} - * among its {@link module:ui/toolbar/toolbarview~ToolbarView#_groupedItems} and - * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems} - * depending on the configuration of the toolbar, the geometry and the number of items. + * ┌──────────────────────────────────────── ToolbarView ───────────────────────────────────────────┐ + * | ┌─────────────────────────────────────── #children ──────────────────────────────────────────┐ | + * | | ┌─────── #itemsView ────────┐ ┌──────────────────────┐ ┌── #_groupedItemsDropdown ───┐ | | + * | | | #_ungroupedItems | | ToolbarSeparatorView | | #_groupedItems | | | + * | | └──────────────────────────-┘ └──────────────────────┘ └─────────────────────────────┘ | | + * | | \---------- only when toolbar items overflow ---------/ | | + * | └────────────────────────────────────────────────────────────────────────────────────────────┘ | + * └────────────────────────────────────────────────────────────────────────────────────────────────┘ * * @private + * @implements module:ui/toolbar/toolbarview~ToolbarFeature */ class DynamicGrouping { /** - * Creates an instance of the {@link module:ui/toolbar/toolbarview~DynamicGrouping} class. - * - * @param {Object} options The configuration of the helper. - * @param {Boolean} options.shouldGroupWhenFull Corresponds to - * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull}. - * @param {module:ui/viewcollection~ViewCollection} options.items Corresponds to - * {@link module:ui/toolbar/toolbarview~ToolbarView#items}. - * @param {module:ui/viewcollection~ViewCollection} options._ungroupedItems Corresponds to - * {@link module:ui/toolbar/toolbarview~ToolbarView#_ungroupedItems}. - * @param {module:ui/viewcollection~ViewCollection} options._groupedItems Corresponds to - * {@link module:ui/toolbar/toolbarview~ToolbarView#_groupedItems}/ - * @param {HTMLElement} options.element Corresponds to {@link module:ui/toolbar/toolbarview~ToolbarView#element}. - * @param {String} options.uiLanguageDirection Corresponds to {@link module:utils/locale~Locale#uiLanguageDirection}. - * @param {Function} options.onGroupStart Executed when the first ungrouped toolbar item gets grouped. - * @param {Function} options.onGroupEnd Executed when the last of the grouped toolbar items just got ungrouped. + * @inheritDoc */ constructor( view ) { this.view = view; /** - * A subset of of toolbar {@link #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 a {@link #_itemsView dedicated view}. + * 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 #items} were grouped, it matches the {@link #items} collection in size and order. - * - * **Note**: Grouping occurs only when the toolbar was - * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull configured}. - * - * See the {@link #_itemsManager} to learn more. + * 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. * * @private * @readonly @@ -425,15 +406,12 @@ class DynamicGrouping { this._ungroupedItems = view.createCollection(); /** - * A subset of of toolbar {@link #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 #items} were grouped, this collection is empty. + * 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}. * - * **Note**: Grouping occurs only when the toolbar was - * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull configured}. - * - * See the {@link #_itemsManager} to learn more. + * When none of the {@link module:ui/toolbar/toolbarview~ToolbarView#items} were grouped, + * this collection is empty. * * @private * @readonly @@ -443,22 +421,21 @@ class DynamicGrouping { /** * The dropdown that aggregates {@link #_groupedItems grouped items} that do not fit into a single - * row of the toolbar. It is displayed on demand at the end of the toolbar and offers another + * 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. * - * See the {@link #_itemsManager} to learn more. - * * @private * @readonly * @member {module:ui/dropdown/dropdownview~DropdownView} */ - this._groupedItemsDropdown = this._createGrouppedItemsDropdown(); + 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 dynamically in {@link #_enableGroupingOnResize}. + * **Note:** Created in {@link #_enableGroupingOnResize}. * * @readonly * @private @@ -472,8 +449,6 @@ class DynamicGrouping { * manages those collections but also is executed upon changes in those collections, this flag * ensures no infinite loops occur. * - * **Note:** Used only if {@link #_enableGroupingOnResize} was called. - * * @readonly * @private * @member {Boolean} @@ -482,11 +457,9 @@ class DynamicGrouping { /** * A cached value of the horizontal padding style used by {@link #_updateGrouping} - * 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:** In use only after {@link #_enableGroupingOnResize} was called. + * 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 * @private @@ -494,16 +467,16 @@ class DynamicGrouping { */ this._cachedPadding = null; - // 1:1 pass–through binding. - view._itemsView.items.bindTo( this._ungroupedItems ).using( item => item ); + // 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 cycleable. + // 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.components.on( 'add', this._updateFocusCycleableItems.bind( this ) ); - view.components.on( 'remove', this._updateFocusCycleableItems.bind( this ) ); + 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. @@ -538,21 +511,25 @@ class DynamicGrouping { view.extendTemplate( { attributes: { class: [ + // To group items dynamically, the toolbar needs a dedicated CSS class. 'ck-toolbar_grouping' ] } } ); } + /** + * @inheritDoc + */ render() { this._enableGroupingOnResize(); } /** - * Cleans up after the manager when its parent toolbar is destroyed. + * @inheritDoc */ destroy() { - // The dropdown may not be in #components at the moment of toolbar destruction + // 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(); @@ -649,9 +626,10 @@ class DynamicGrouping { } /** - * Returns `true` when {@link #element} children visually overflow, for instance if the - * toolbar is narrower than its members. `false` otherwise. + * 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() { @@ -689,14 +667,14 @@ class DynamicGrouping { * When called it will remove the last item from {@link #_ungroupedItems} and move it to the * {@link #_groupedItems} collection. * - * @protected + * @private */ _groupLastItem() { const view = this.view; if ( !this._groupedItems.length ) { - view.components.add( new ToolbarSeparatorView() ); - view.components.add( this._groupedItemsDropdown ); + view.children.add( new ToolbarSeparatorView() ); + view.children.add( this._groupedItemsDropdown ); view.focusTracker.add( this._groupedItemsDropdown.element ); } @@ -709,7 +687,7 @@ class DynamicGrouping { * Moves the very first item from the toolbar belonging to {@link #_groupedItems} back * to the {@link #_ungroupedItems} collection. * - * @protected + * @private */ _ungroupFirstItem() { const view = this.view; @@ -717,8 +695,8 @@ class DynamicGrouping { this._ungroupedItems.add( this._groupedItems.remove( this._groupedItems.first ) ); if ( !this._groupedItems.length ) { - view.components.remove( this._groupedItemsDropdown ); - view.components.remove( view.components.last ); + view.children.remove( this._groupedItemsDropdown ); + view.children.remove( view.children.last ); view.focusTracker.remove( this._groupedItemsDropdown.element ); } } @@ -730,7 +708,7 @@ class DynamicGrouping { * @private * @returns {module:ui/dropdown/dropdownview~DropdownView} */ - _createGrouppedItemsDropdown() { + _createGroupedItemsDropdown() { const view = this.view; const t = view.t; const locale = view.locale; @@ -752,11 +730,14 @@ class DynamicGrouping { } /** - * A method that updates the {@link #focusables focus–cycleable items} + * 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. * - * See the {@link #focusables collection} documentation to learn more about the purpose - * of this method. + * For instance, the {@link #_groupedItemsDropdown} can show up and hide but when it is visible, + * it must be subject to focus cycling in the toolbar. + * + * See the {@link module:ui/toolbar/toolbarview~ToolbarView#focusables collection} documentation + * to learn more about the purpose of this method. * * @private */ @@ -784,7 +765,49 @@ class DynamicGrouping { /** * 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 is narrow. + * instance, if the parent container of the toolbar is narrow. * * @member {Boolean} module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull */ + +/** + * A class interface defining a (sub–)feature of the {@link module:ui/toolbar/toolbarview~ToolbarView}. + * + * Toolbar features extend its look and behavior 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~ToolbarFeature + */ + +/** + * Creates a new toolbar feature 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, defined extra toolbar properties, etc.. + * + * @method #constructor + * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this feature + * is added to. + */ + +/** + * A method called after the toolbar has been {@link module:ui/toolbar/toolbarview~ToolbarView#render rendered}. + * E.g. it can be used to customize the behavior of the toolbar when its {@link module:ui/toolbar/toolbarview~ToolbarView#element} + * is available. + * + * @readonly + * @member {Function} #render + */ + +/** + * A method called after the toolbar has been {@link module:ui/toolbar/toolbarview~ToolbarView#destroy destroyed}. + * It allows cleaning up after the toolbar feature, for instance, this is the right place to detach + * event listeners, free up references, etc.. + * + * @readonly + * @member {Function} #destroy + */ From bfb34871532d15f9951cb6df5cac9b9f67fe6019 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 14 Oct 2019 12:24:01 +0200 Subject: [PATCH 31/39] Removed obsolete lock property. --- src/toolbar/toolbarview.js | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 4b6e1d21..e590e115 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -443,18 +443,6 @@ class DynamicGrouping { */ this._resizeObserver = null; - /** - * A flag used by {@link #_updateGrouping} method to make sure no concurrent updates - * are performed to the {@link #_ungroupedItems} and {@link #_groupedItems}. Because {@link #_updateGrouping} - * manages those collections but also is executed upon changes in those collections, this flag - * ensures no infinite loops occur. - * - * @readonly - * @private - * @member {Boolean} - */ - this._updateLock = false; - /** * 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 @@ -545,13 +533,6 @@ class DynamicGrouping { * without the toolbar wrapping. */ _updateGrouping() { - // 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; - } - const view = this.view; // Do no grouping–related geometry analysis when the toolbar is detached from visible DOM, @@ -562,8 +543,6 @@ class DynamicGrouping { return; } - this._updateLock = true; - let wereItemsGrouped; // Group #items as long as some wrap to the next row. This will happen, for instance, @@ -592,8 +571,6 @@ class DynamicGrouping { this._groupLastItem(); } } - - this._updateLock = false; } /** From f53d9795ea2c2660fefff4821880d8bb0917cf81 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 14 Oct 2019 12:35:04 +0200 Subject: [PATCH 32/39] Docs. --- src/toolbar/toolbarview.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index e590e115..69ce8a94 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -531,6 +531,8 @@ class DynamicGrouping { * 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. + * + * @private */ _updateGrouping() { const view = this.view; @@ -582,6 +584,8 @@ class DynamicGrouping { * them in the dropdown if necessary. It will also observe the browser window for size changes in * the future and respond to them by grouping more items or reverting already grouped back, depending * on the visual space available. + * + * @private */ _enableGroupingOnResize() { const view = this.view; @@ -764,7 +768,7 @@ class DynamicGrouping { * * 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, defined extra toolbar properties, etc.. + * the toolbar, define extra toolbar properties, etc.. * * @method #constructor * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this feature From 7c7cb173366fe4640e315b842ee3ae936c103b98 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 14 Oct 2019 15:17:55 +0200 Subject: [PATCH 33/39] Tests: Stabilized ToolbarView tests after refactoring. --- src/toolbar/toolbarview.js | 10 +- tests/toolbar/toolbarview.js | 832 ++++++++++++++++------------------- 2 files changed, 389 insertions(+), 453 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 69ce8a94..ae2b27a3 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -187,7 +187,7 @@ export default class ToolbarView extends View { * * See {@link module:ui/toolbar/toolbarview~ToolbarFeature} to learn more. * - * @private + * @protected * @readonly * @member {module:ui/toolbar/toolbarview~ToolbarFeature} */ @@ -399,7 +399,7 @@ class DynamicGrouping { * 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. * - * @private + * @protected * @readonly * @member {module:ui/viewcollection~ViewCollection} */ @@ -413,7 +413,7 @@ class DynamicGrouping { * When none of the {@link module:ui/toolbar/toolbarview~ToolbarView#items} were grouped, * this collection is empty. * - * @private + * @protected * @readonly * @member {module:ui/viewcollection~ViewCollection} */ @@ -425,7 +425,7 @@ class DynamicGrouping { * {@link module:ui/toolbar/toolbarview~ToolbarView#children toolbar children} and offers another * (nested) toolbar which displays items that would normally overflow. * - * @private + * @protected * @readonly * @member {module:ui/dropdown/dropdownview~DropdownView} */ @@ -532,7 +532,7 @@ class DynamicGrouping { * {@link #_groupedItems} to be returned back to {@link #_ungroupedItems} and still fit into a single row * without the toolbar wrapping. * - * @private + * @protected */ _updateGrouping() { const view = this.view; diff --git a/tests/toolbar/toolbarview.js b/tests/toolbar/toolbarview.js index a5fc290b..35f6cb71 100644 --- a/tests/toolbar/toolbarview.js +++ b/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 #_feature', () => { + expect( view._feature ).to.be.an.instanceOf( 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,39 @@ 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 _feature#render()', () => { + const view = new ToolbarView( locale ); + sinon.spy( view._feature, 'render' ); - view.keystrokes.press( keyEvtData ); + view.render(); + sinon.assert.calledOnce( view._feature.render ); - 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._feature, 'destroy' ); view.destroy(); - sinon.assert.calledOnce( view.groupedItemsDropdown.destroy ); - view.element.remove(); + sinon.assert.calledOnce( view._feature.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 _feature#destroy()', () => { + sinon.spy( view._feature, 'destroy' ); view.destroy(); - sinon.assert.calledOnce( view._groupWhenFullResizeObserver.disconnect ); - view.element.remove(); + sinon.assert.calledOnce( view._feature.destroy ); } ); } ); @@ -538,20 +335,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 +353,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 +395,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._feature._groupedItems; + ungroupedItems = view._feature._ungroupedItems; + groupedItemsDropdown = view._feature._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._feature, '_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._feature._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._feature, '_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._feature._updateGrouping ); + + view.items.remove( 0 ); + sinon.assert.calledTwice( view._feature._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 +539,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 +558,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 #_feature._groupedItems after an attempt was made. + view._feature._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 +579,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._feature._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._feature._resizeObserver.observe ); + sinon.assert.calledWithExactly( view._feature._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._feature, '_updateGrouping' ); + view.element.style.width = '500px'; + + setTimeout( () => { + sinon.assert.calledOnce( view._feature._updateGrouping ); + view.element.style.height = '500px'; + + setTimeout( () => { + sinon.assert.calledOnce( view._feature._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._feature, '_updateGrouping' ); + + view.render(); - const dropdown = view.groupedItemsDropdown; + view._feature._resizeObserver.callback( [ + { contentRect: { width: 42 } } + ] ); + + sinon.assert.calledTwice( view._feature._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._feature._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._feature._resizeObserver, 'disconnect' ); + + view.destroy(); + sinon.assert.calledOnce( view._feature._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 +789,14 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); view.items.add( focusable() ); - view.shouldGroupWhenFull = true; - - expect( view.groupedItems ).to.have.length( 1 ); + expect( view._feature._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 +815,50 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); view.items.add( focusable() ); - view.shouldGroupWhenFull = true; - - expect( view.groupedItems ).to.have.length( 1 ); + expect( view._feature._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(); + } ); } ); } ); } ); From 436464bc30184482c8e52ceb44d52f99617bb527 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 14 Oct 2019 15:54:40 +0200 Subject: [PATCH 34/39] Code refactoring. --- src/toolbar/toolbarview.js | 233 ++++++++++++++++++++--------------- tests/toolbar/toolbarview.js | 25 ++-- 2 files changed, 144 insertions(+), 114 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index ae2b27a3..d78de7f7 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -216,7 +216,7 @@ export default class ToolbarView extends View { // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); - this._feature.render(); + this._feature.render( this ); } /** @@ -330,9 +330,7 @@ class StaticLayout { * @inheritDoc */ constructor( view ) { - this.view = view; - - const bind = this.view.bindTemplate; + const bind = view.bindTemplate; // Static toolbar can be vertical when needed. view.set( 'isVertical', false ); @@ -371,14 +369,14 @@ class StaticLayout { * 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 ---------/ | | - * | └────────────────────────────────────────────────────────────────────────────────────────────┘ | - * └────────────────────────────────────────────────────────────────────────────────────────────────┘ + * ┌──────────────────────────────────────── ToolbarView ──────────────────────────────────────────┐ + * | ┌─────────────────────────────────────── #children ─────────────────────────────────────────┐ | + * | | ┌─────── #itemsView ────────┐ ┌──────────────────────┐ ┌── #groupedItemsDropdown ───┐ | | + * | | | #ungroupedItems | | ToolbarSeparatorView | | #groupedItems | | | + * | | └──────────────────────────-┘ └──────────────────────┘ └────────────────────────────┘ | | + * | | \---------- only when toolbar items overflow --------/ | | + * | └───────────────────────────────────────────────────────────────────────────────────────────┘ | + * └───────────────────────────────────────────────────────────────────────────────────────────────┘ * * @private * @implements module:ui/toolbar/toolbarview~ToolbarFeature @@ -388,48 +386,90 @@ class DynamicGrouping { * @inheritDoc */ constructor( view ) { - this.view = 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 + * 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. * - * @protected * @readonly * @member {module:ui/viewcollection~ViewCollection} */ - this._ungroupedItems = view.createCollection(); + 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}. + * 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. * - * @protected * @readonly * @member {module:ui/viewcollection~ViewCollection} */ - this._groupedItems = view.createCollection(); + this.groupedItems = view.createCollection(); /** - * The dropdown that aggregates {@link #_groupedItems grouped items} that do not fit into a single + * 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. * - * @protected * @readonly * @member {module:ui/dropdown/dropdownview~DropdownView} */ - this._groupedItemsDropdown = this._createGroupedItemsDropdown(); + this.groupedItemsDropdown = this._createGroupedItemsDropdown(); /** * An instance of the resize observer that helps dynamically determine the geometry of the toolbar @@ -438,10 +478,9 @@ class DynamicGrouping { * **Note:** Created in {@link #_enableGroupingOnResize}. * * @readonly - * @private * @member {module:utils/dom/getresizeobserver~ResizeObserver} */ - this._resizeObserver = null; + this.resizeObserver = null; /** * A cached value of the horizontal padding style used by {@link #_updateGrouping} @@ -450,34 +489,33 @@ class DynamicGrouping { * the padding will change and re–using `Window.getComputedStyle()` is expensive. * * @readonly - * @private * @member {Number} */ - this._cachedPadding = null; + this.cachedPadding = null; // Only those items that were not grouped are visible to the user. - view.itemsView.children.bindTo( this._ungroupedItems ).using( item => item ); + 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 ) ); + 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. + // 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 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 ); + if ( index > this.ungroupedItems.length ) { + this.groupedItems.add( item, index - this.ungroupedItems.length ); } else { - this._ungroupedItems.add( item, index ); + this.ungroupedItems.add( item, index ); } - // When a new ungrouped item joins in and lands in #_ungroupedItems, there's a chance it causes + // When a new ungrouped item joins in and lands in #ungroupedItems, there's a chance it causes // the toolbar to overflow. this._updateGrouping(); } ); @@ -485,10 +523,10 @@ class DynamicGrouping { // 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 ); + if ( index > this.ungroupedItems.length ) { + this.groupedItems.remove( item ); } else { - this._ungroupedItems.remove( item ); + this.ungroupedItems.remove( item ); } // Whether removed from grouped or ungrouped items, there is a chance @@ -509,7 +547,9 @@ class DynamicGrouping { /** * @inheritDoc */ - render() { + render( view ) { + this.viewElement = view.element; + this._enableGroupingOnResize(); } @@ -519,29 +559,27 @@ class DynamicGrouping { 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.groupedItemsDropdown.destroy(); - this._resizeObserver.disconnect(); + 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. + * 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 + * {@link #groupedItems} to be returned back to {@link #ungroupedItems} and still fit into a single row * without the toolbar wrapping. * * @protected */ _updateGrouping() { - const view = this.view; - // 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 ( !view.element.ownerDocument.body.contains( view.element ) ) { + if ( !this.viewElement.ownerDocument.body.contains( this.viewElement ) ) { return; } @@ -559,9 +597,9 @@ class DynamicGrouping { // 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 ) { + 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 ) { + while ( this.groupedItems.length && !this._areItemsOverflowing ) { this._ungroupFirstItem(); } @@ -576,11 +614,11 @@ class DynamicGrouping { } /** - * Enables the functionality that prevents {@link #_ungroupedItems} from overflowing + * 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. + * {@link #groupedItems} collection and displayed in a dropdown at the end of the space, which has its own nested toolbar. * - * When called, the toolbar will automatically analyze the location of its {@link #_ungroupedItems} and "group" + * 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. @@ -588,12 +626,10 @@ class DynamicGrouping { * @private */ _enableGroupingOnResize() { - const view = this.view; - let previousWidth; // TODO: Consider debounce. - this._resizeObserver = getResizeObserver( ( [ entry ] ) => { + this.resizeObserver = getResizeObserver( ( [ entry ] ) => { if ( !previousWidth || previousWidth !== entry.contentRect.width ) { this._updateGrouping(); @@ -601,7 +637,7 @@ class DynamicGrouping { } } ); - this._resizeObserver.observe( view.element ); + this.resizeObserver.observe( this.viewElement ); this._updateGrouping(); } @@ -615,106 +651,100 @@ class DynamicGrouping { */ get _areItemsOverflowing() { // An empty toolbar cannot overflow. - if ( !this._ungroupedItems.length ) { + if ( !this.ungroupedItems.length ) { return false; } - const view = this.view; - const element = view.element; - const uiLanguageDirection = view.locale.uiLanguageDirection; + const element = this.viewElement; + const uiLanguageDirection = this.viewLocale.uiLanguageDirection; const lastChildRect = new Rect( element.lastChild ); const toolbarRect = new Rect( element ); - if ( !this._cachedPadding ) { + if ( !this.cachedPadding ) { const computedStyle = global.window.getComputedStyle( element ); const paddingProperty = uiLanguageDirection === 'ltr' ? 'paddingRight' : 'paddingLeft'; // parseInt() is essential because of quirky floating point numbers logic and DOM. // If the padding turned out too big because of that, the grouped items dropdown would // always look (from the Rect perspective) like it overflows (while it's not). - this._cachedPadding = Number.parseInt( computedStyle[ paddingProperty ] ); + this.cachedPadding = Number.parseInt( computedStyle[ paddingProperty ] ); } if ( uiLanguageDirection === 'ltr' ) { - return lastChildRect.right > toolbarRect.right - this._cachedPadding; + return lastChildRect.right > toolbarRect.right - this.cachedPadding; } else { - return lastChildRect.left < toolbarRect.left + this._cachedPadding; + return lastChildRect.left < toolbarRect.left + this.cachedPadding; } } /** * The opposite of {@link #_ungroupFirstItem}. * - * When called it will remove the last item from {@link #_ungroupedItems} and move it to the - * {@link #_groupedItems} collection. + * When called it will remove the last item from {@link #ungroupedItems} and move it to the + * {@link #groupedItems} collection. * * @private */ _groupLastItem() { - const view = this.view; - - if ( !this._groupedItems.length ) { - view.children.add( new ToolbarSeparatorView() ); - view.children.add( this._groupedItemsDropdown ); - view.focusTracker.add( this._groupedItemsDropdown.element ); + 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 ); + this.groupedItems.add( this.ungroupedItems.remove( this.ungroupedItems.last ), 0 ); } /** * The opposite of {@link #_groupLastItem}. * - * Moves the very first item from the toolbar belonging to {@link #_groupedItems} back - * to the {@link #_ungroupedItems} collection. + * Moves the very first item from the toolbar belonging to {@link #groupedItems} back + * to the {@link #ungroupedItems} collection. * * @private */ _ungroupFirstItem() { - const view = this.view; + this.ungroupedItems.add( this.groupedItems.remove( this.groupedItems.first ) ); - this._ungroupedItems.add( this._groupedItems.remove( this._groupedItems.first ) ); - - if ( !this._groupedItems.length ) { - view.children.remove( this._groupedItemsDropdown ); - view.children.remove( view.children.last ); - view.focusTracker.remove( this._groupedItemsDropdown.element ); + if ( !this.groupedItems.length ) { + this.viewChildren.remove( this.groupedItemsDropdown ); + this.viewChildren.remove( this.viewChildren.last ); + this.viewFocusTracker.remove( this.groupedItemsDropdown.element ); } } /** - * Creates the {@link #_groupedItemsDropdown} that hosts the members of the {@link #_groupedItems} + * Creates the {@link #groupedItemsDropdown} that hosts the members of the {@link #groupedItems} * collection when there is not enough space in the toolbar to display all items in a single row. * * @private * @returns {module:ui/dropdown/dropdownview~DropdownView} */ _createGroupedItemsDropdown() { - const view = this.view; - const t = view.t; - const locale = view.locale; - const _groupedItemsDropdown = createDropdown( locale ); + const locale = this.viewLocale; + const t = locale.t; + const dropdown = createDropdown( locale ); - _groupedItemsDropdown.class = 'ck-toolbar__grouped-dropdown'; - addToolbarToDropdown( _groupedItemsDropdown, [] ); + dropdown.class = 'ck-toolbar__grouped-dropdown'; + addToolbarToDropdown( dropdown, [] ); - _groupedItemsDropdown.buttonView.set( { + dropdown.buttonView.set( { label: t( 'Show more items' ), tooltip: true, icon: verticalDotsIcon } ); // 1:1 pass–through binding. - _groupedItemsDropdown.toolbarView.items.bindTo( this._groupedItems ).using( item => item ); + dropdown.toolbarView.items.bindTo( this.groupedItems ).using( item => item ); - return _groupedItemsDropdown; + return dropdown; } /** * A method that updates the {@link module:ui/toolbar/toolbarview~ToolbarView#focusables focus–cycleable items} * collection so it represents the up–to–date state of the UI from the perspective of the user. * - * For instance, the {@link #_groupedItemsDropdown} can show up and hide but when it is visible, + * For instance, the {@link #groupedItemsDropdown} can show up and hide but when it is visible, * it must be subject to focus cycling in the toolbar. * * See the {@link module:ui/toolbar/toolbarview~ToolbarView#focusables collection} documentation @@ -723,16 +753,14 @@ class DynamicGrouping { * @private */ _updateFocusCycleableItems() { - const view = this.view; - - view.focusables.clear(); + this.viewFocusables.clear(); - this._ungroupedItems.map( item => { - view.focusables.add( item ); + this.ungroupedItems.map( item => { + this.viewFocusables.add( item ); } ); - if ( this._groupedItems.length ) { - view.focusables.add( this._groupedItemsDropdown ); + if ( this.groupedItems.length ) { + this.viewFocusables.add( this.groupedItemsDropdown ); } } } @@ -771,7 +799,7 @@ class DynamicGrouping { * the toolbar, define extra toolbar properties, etc.. * * @method #constructor - * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this feature + * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this feature. * is added to. */ @@ -782,6 +810,7 @@ class DynamicGrouping { * * @readonly * @member {Function} #render + * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar being rendered. */ /** diff --git a/tests/toolbar/toolbarview.js b/tests/toolbar/toolbarview.js index 35f6cb71..4f7e9e4b 100644 --- a/tests/toolbar/toolbarview.js +++ b/tests/toolbar/toolbarview.js @@ -299,6 +299,7 @@ describe( 'ToolbarView', () => { view.render(); sinon.assert.calledOnce( view._feature.render ); + sinon.assert.calledWithExactly( view._feature.render, view ); view.destroy(); } ); @@ -451,9 +452,9 @@ describe( 'ToolbarView', () => { view.element.style.width = '200px'; document.body.appendChild( view.element ); - groupedItems = view._feature._groupedItems; - ungroupedItems = view._feature._ungroupedItems; - groupedItemsDropdown = view._feature._groupedItemsDropdown; + groupedItems = view._feature.groupedItems; + ungroupedItems = view._feature.ungroupedItems; + groupedItemsDropdown = view._feature.groupedItemsDropdown; } ); afterEach( () => { @@ -564,7 +565,7 @@ describe( 'ToolbarView', () => { view.element.style.width = '350px'; // Some grouped items cannot be ungrouped because there is not enough space and they will - // land back in #_feature._groupedItems after an attempt was made. + // land back in #_feature.groupedItems after an attempt was made. view._feature._updateGrouping(); expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA, itemB, itemC ] ); expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemD ] ); @@ -609,8 +610,8 @@ describe( 'ToolbarView', () => { view.render(); - sinon.assert.calledOnce( view._feature._resizeObserver.observe ); - sinon.assert.calledWithExactly( view._feature._resizeObserver.observe, view.element ); + sinon.assert.calledOnce( view._feature.resizeObserver.observe ); + sinon.assert.calledWithExactly( view._feature.resizeObserver.observe, view.element ); view.destroy(); } ); @@ -701,7 +702,7 @@ describe( 'ToolbarView', () => { view.render(); - view._feature._resizeObserver.callback( [ + view._feature.resizeObserver.callback( [ { contentRect: { width: 42 } } ] ); @@ -736,7 +737,7 @@ describe( 'ToolbarView', () => { sinon.assert.calledOnce( groupedItemsDropdown.destroy ); } ); - it( 'disconnects the #_resizeObserver', () => { + it( 'disconnects the #resizeObserver', () => { view.element.style.width = '200px'; const itemA = focusable(); @@ -749,10 +750,10 @@ describe( 'ToolbarView', () => { view.items.add( itemC ); view.items.add( itemD ); - sinon.spy( view._feature._resizeObserver, 'disconnect' ); + sinon.spy( view._feature.resizeObserver, 'disconnect' ); view.destroy(); - sinon.assert.calledOnce( view._feature._resizeObserver.disconnect ); + sinon.assert.calledOnce( view._feature.resizeObserver.disconnect ); } ); } ); @@ -789,7 +790,7 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); view.items.add( focusable() ); - expect( view._feature._groupedItems ).to.have.length( 1 ); + expect( view._feature.groupedItems ).to.have.length( 1 ); } ); it( 'considers the left padding of the toolbar (RTL UI)', () => { @@ -815,7 +816,7 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); view.items.add( focusable() ); - expect( view._feature._groupedItems ).to.have.length( 1 ); + expect( view._feature.groupedItems ).to.have.length( 1 ); view.destroy(); view.element.remove(); From 384c2e7ffbbed860f33d9be53563f0335d75d887 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 14 Oct 2019 16:25:55 +0200 Subject: [PATCH 35/39] Code refactoring. --- src/toolbar/toolbarview.js | 52 ++++++++++++++++----------- tests/toolbar/toolbarview.js | 70 ++++++++++++++++++------------------ 2 files changed, 66 insertions(+), 56 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index d78de7f7..2c01cb30 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -183,15 +183,15 @@ export default class ToolbarView extends View { } ); /** - * An instance of the active toolbar feature that shapes its look and behavior. + * An instance of the active toolbar behavior that shapes its look and functionality. * - * See {@link module:ui/toolbar/toolbarview~ToolbarFeature} to learn more. + * See {@link module:ui/toolbar/toolbarview~ToolbarBehavior} to learn more. * * @protected * @readonly - * @member {module:ui/toolbar/toolbarview~ToolbarFeature} + * @member {module:ui/toolbar/toolbarview~ToolbarBehavior} */ - this._feature = this.options.shouldGroupWhenFull ? new DynamicGrouping( this ) : new StaticLayout( this ); + this._behavior = this.options.shouldGroupWhenFull ? new DynamicGrouping( this ) : new StaticLayout( this ); } /** @@ -216,14 +216,14 @@ export default class ToolbarView extends View { // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); - this._feature.render( this ); + this._behavior.render( this ); } /** * @inheritDoc */ destroy() { - this._feature.destroy(); + this._behavior.destroy(); return super.destroy(); } @@ -319,15 +319,19 @@ class ItemsView extends View { } /** - * A toolbar feature that makes it static and unresponsive to the changes of the environment. + * 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~ToolbarFeature + * @implements module:ui/toolbar/toolbarview~ToolbarBehavior */ class StaticLayout { /** - * @inheritDoc + * Creates an instance of the {@link module:ui/toolbar/toolbarview~StaticLayout} toolbar + * behavior. + * + * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior + * is added to. */ constructor( view ) { const bind = view.bindTemplate; @@ -363,7 +367,7 @@ class StaticLayout { } /** - * A toolbar feature that makes its items respond to the changes in the geometry. + * 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). @@ -379,11 +383,15 @@ class StaticLayout { * └───────────────────────────────────────────────────────────────────────────────────────────────┘ * * @private - * @implements module:ui/toolbar/toolbarview~ToolbarFeature + * @implements module:ui/toolbar/toolbarview~ToolbarBehavior */ class DynamicGrouping { /** - * @inheritDoc + * 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 ) { /** @@ -545,7 +553,10 @@ class DynamicGrouping { } /** - * @inheritDoc + * 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; @@ -554,7 +565,7 @@ class DynamicGrouping { } /** - * @inheritDoc + * Cleans up the internals used by this behavior. */ destroy() { // The dropdown may not be in ToolbarView#children at the moment of toolbar destruction @@ -780,27 +791,26 @@ class DynamicGrouping { */ /** - * A class interface defining a (sub–)feature of the {@link module:ui/toolbar/toolbarview~ToolbarView}. + * A class interface defining a behavior of the {@link module:ui/toolbar/toolbarview~ToolbarView}. * - * Toolbar features extend its look and behavior and have an impact on the + * 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~ToolbarFeature + * @interface module:ui/toolbar/toolbarview~ToolbarBehavior */ /** - * Creates a new toolbar feature instance. + * 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 feature. - * is added to. + * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior is added to. */ /** @@ -815,7 +825,7 @@ class DynamicGrouping { /** * A method called after the toolbar has been {@link module:ui/toolbar/toolbarview~ToolbarView#destroy destroyed}. - * It allows cleaning up after the toolbar feature, for instance, this is the right place to detach + * It allows cleaning up after the toolbar behavior, for instance, this is the right place to detach * event listeners, free up references, etc.. * * @readonly diff --git a/tests/toolbar/toolbarview.js b/tests/toolbar/toolbarview.js index 4f7e9e4b..52725e23 100644 --- a/tests/toolbar/toolbarview.js +++ b/tests/toolbar/toolbarview.js @@ -95,8 +95,8 @@ describe( 'ToolbarView', () => { expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); } ); - it( 'creates #_feature', () => { - expect( view._feature ).to.be.an.instanceOf( Object ); + it( 'creates #_behavior', () => { + expect( view._behavior ).to.be.an( 'object' ); } ); } ); @@ -293,13 +293,13 @@ describe( 'ToolbarView', () => { } ); } ); - it( 'calls _feature#render()', () => { + it( 'calls _behavior#render()', () => { const view = new ToolbarView( locale ); - sinon.spy( view._feature, 'render' ); + sinon.spy( view._behavior, 'render' ); view.render(); - sinon.assert.calledOnce( view._feature.render ); - sinon.assert.calledWithExactly( view._feature.render, view ); + sinon.assert.calledOnce( view._behavior.render ); + sinon.assert.calledWithExactly( view._behavior.render, view ); view.destroy(); } ); @@ -307,18 +307,18 @@ describe( 'ToolbarView', () => { describe( 'destroy()', () => { it( 'destroys the feature', () => { - sinon.spy( view._feature, 'destroy' ); + sinon.spy( view._behavior, 'destroy' ); view.destroy(); - sinon.assert.calledOnce( view._feature.destroy ); + sinon.assert.calledOnce( view._behavior.destroy ); } ); - it( 'calls _feature#destroy()', () => { - sinon.spy( view._feature, 'destroy' ); + it( 'calls _behavior#destroy()', () => { + sinon.spy( view._behavior, 'destroy' ); view.destroy(); - sinon.assert.calledOnce( view._feature.destroy ); + sinon.assert.calledOnce( view._behavior.destroy ); } ); } ); @@ -452,9 +452,9 @@ describe( 'ToolbarView', () => { view.element.style.width = '200px'; document.body.appendChild( view.element ); - groupedItems = view._feature.groupedItems; - ungroupedItems = view._feature.ungroupedItems; - groupedItemsDropdown = view._feature.groupedItemsDropdown; + groupedItems = view._behavior.groupedItems; + ungroupedItems = view._behavior.ungroupedItems; + groupedItemsDropdown = view._behavior.groupedItemsDropdown; } ); afterEach( () => { @@ -469,7 +469,7 @@ describe( 'ToolbarView', () => { } ); it( 'updates the UI as new #items are added', () => { - sinon.spy( view._feature, '_updateGrouping' ); + sinon.spy( view._behavior, '_updateGrouping' ); const itemA = focusable(); const itemB = focusable(); @@ -481,7 +481,7 @@ describe( 'ToolbarView', () => { view.items.add( itemA ); view.items.add( itemB ); - sinon.assert.calledTwice( view._feature._updateGrouping ); + sinon.assert.calledTwice( view._behavior._updateGrouping ); expect( ungroupedItems ).to.have.length( 2 ); expect( groupedItems ).to.have.length( 0 ); @@ -514,16 +514,16 @@ describe( 'ToolbarView', () => { view.items.add( itemC ); view.items.add( itemD ); - sinon.spy( view._feature, '_updateGrouping' ); + 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._feature._updateGrouping ); + sinon.assert.calledOnce( view._behavior._updateGrouping ); view.items.remove( 0 ); - sinon.assert.calledTwice( view._feature._updateGrouping ); + sinon.assert.calledTwice( view._behavior._updateGrouping ); expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemD ] ); } ); @@ -565,8 +565,8 @@ describe( 'ToolbarView', () => { view.element.style.width = '350px'; // Some grouped items cannot be ungrouped because there is not enough space and they will - // land back in #_feature.groupedItems after an attempt was made. - view._feature._updateGrouping(); + // 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 ] ); } ); @@ -586,7 +586,7 @@ describe( 'ToolbarView', () => { view.element.style.width = '350px'; // All grouped items will be ungrouped because they fit just alright in the main space. - view._feature._updateGrouping(); + 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 ); @@ -610,8 +610,8 @@ describe( 'ToolbarView', () => { view.render(); - sinon.assert.calledOnce( view._feature.resizeObserver.observe ); - sinon.assert.calledWithExactly( view._feature.resizeObserver.observe, view.element ); + sinon.assert.calledOnce( view._behavior.resizeObserver.observe ); + sinon.assert.calledWithExactly( view._behavior.resizeObserver.observe, view.element ); view.destroy(); } ); @@ -670,15 +670,15 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); view.items.add( focusable() ); - sinon.spy( view._feature, '_updateGrouping' ); + sinon.spy( view._behavior, '_updateGrouping' ); view.element.style.width = '500px'; setTimeout( () => { - sinon.assert.calledOnce( view._feature._updateGrouping ); + sinon.assert.calledOnce( view._behavior._updateGrouping ); view.element.style.height = '500px'; setTimeout( () => { - sinon.assert.calledOnce( view._feature._updateGrouping ); + sinon.assert.calledOnce( view._behavior._updateGrouping ); done(); }, 100 ); }, 100 ); @@ -698,15 +698,15 @@ describe( 'ToolbarView', () => { shouldGroupWhenFull: true } ); - testUtils.sinon.spy( view._feature, '_updateGrouping' ); + testUtils.sinon.spy( view._behavior, '_updateGrouping' ); view.render(); - view._feature.resizeObserver.callback( [ + view._behavior.resizeObserver.callback( [ { contentRect: { width: 42 } } ] ); - sinon.assert.calledTwice( view._feature._updateGrouping ); + sinon.assert.calledTwice( view._behavior._updateGrouping ); view.destroy(); } ); @@ -731,7 +731,7 @@ describe( 'ToolbarView', () => { view.element.style.width = '500px'; // The dropdown hides; it does not belong to any collection but it still exist. - view._feature._updateGrouping(); + view._behavior._updateGrouping(); view.destroy(); sinon.assert.calledOnce( groupedItemsDropdown.destroy ); @@ -750,10 +750,10 @@ describe( 'ToolbarView', () => { view.items.add( itemC ); view.items.add( itemD ); - sinon.spy( view._feature.resizeObserver, 'disconnect' ); + sinon.spy( view._behavior.resizeObserver, 'disconnect' ); view.destroy(); - sinon.assert.calledOnce( view._feature.resizeObserver.disconnect ); + sinon.assert.calledOnce( view._behavior.resizeObserver.disconnect ); } ); } ); @@ -790,7 +790,7 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); view.items.add( focusable() ); - expect( view._feature.groupedItems ).to.have.length( 1 ); + expect( view._behavior.groupedItems ).to.have.length( 1 ); } ); it( 'considers the left padding of the toolbar (RTL UI)', () => { @@ -816,7 +816,7 @@ describe( 'ToolbarView', () => { view.items.add( focusable() ); view.items.add( focusable() ); - expect( view._feature.groupedItems ).to.have.length( 1 ); + expect( view._behavior.groupedItems ).to.have.length( 1 ); view.destroy(); view.element.remove(); From f87eaca9a6f13c3dd390aa2f7279693ce1663ca6 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 15 Oct 2019 11:01:49 +0200 Subject: [PATCH 36/39] Update src/toolbar/toolbarview.js Co-Authored-By: Maciej --- src/toolbar/toolbarview.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 2c01cb30..72c5ba8c 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -200,7 +200,7 @@ export default class ToolbarView extends View { render() { super.render(); - // children added before rendering should be known to the #focusTracker. + // Children added before rendering should be known to the #focusTracker. for ( const item of this.items ) { this.focusTracker.add( item.element ); } From ed780e5c670e6480bf167c2939ca3b388a584d11 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 15 Oct 2019 11:02:24 +0200 Subject: [PATCH 37/39] Code refactoring: Removed obsolete ToolbarView docs. --- src/toolbar/toolbarview.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 72c5ba8c..06e8577e 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -250,9 +250,6 @@ export default class ToolbarView extends View { * @param {module:ui/componentfactory~ComponentFactory} factory A factory producing toolbar items. */ fillFromConfig( config, factory ) { - // 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.map( name => { if ( name == '|' ) { this.items.add( new ToolbarSeparatorView() ); From d900dd71a977aae013c288176d2a475ddd025a89 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 15 Oct 2019 11:05:03 +0200 Subject: [PATCH 38/39] Docs: Improvements in ToolbarView docs. --- src/toolbar/toolbarview.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 06e8577e..69270ee8 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -317,7 +317,8 @@ class ItemsView extends View { /** * A toolbar behavior that makes it static and unresponsive to the changes of the environment. - * It also allows toolbar with the vertical layout. + * At the same time, it also makes it possible to display a toolbar with a vertical layout + * using the {@link module:ui/toolbar/toolbarview~ToolbarView#isVertical} property. * * @private * @implements module:ui/toolbar/toolbarview~ToolbarBehavior From 8c47c138aa0cf7160abe367d2c5199eeb3ed48d5 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 15 Oct 2019 11:12:30 +0200 Subject: [PATCH 39/39] Code refactoring: Improved docs, re-organized some methods, fixed minor code issues. --- src/toolbar/toolbarview.js | 72 ++++++++++++++-------------- theme/components/toolbar/toolbar.css | 7 +-- 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 69270ee8..d26a66c9 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -606,7 +606,7 @@ class DynamicGrouping { // 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 ) { + if ( !wereItemsGrouped && this.groupedItems.length ) { // Ungroup items as long as none are overflowing or there are none to ungroup left. while ( this.groupedItems.length && !this._areItemsOverflowing ) { this._ungroupFirstItem(); @@ -622,35 +622,6 @@ class DynamicGrouping { } } - /** - * 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. - * - * When called, the toolbar will automatically analyze the location of its {@link #ungroupedItems} and "group" - * them in the dropdown if necessary. It will also observe the browser window for size changes in - * the future and respond to them by grouping more items or reverting already grouped back, depending - * on the visual space available. - * - * @private - */ - _enableGroupingOnResize() { - let previousWidth; - - // TODO: Consider debounce. - this.resizeObserver = getResizeObserver( ( [ entry ] ) => { - if ( !previousWidth || previousWidth !== entry.contentRect.width ) { - this._updateGrouping(); - - previousWidth = entry.contentRect.width; - } - } ); - - this.resizeObserver.observe( this.viewElement ); - - this._updateGrouping(); - } - /** * 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. @@ -687,10 +658,39 @@ class DynamicGrouping { } /** - * The opposite of {@link #_ungroupFirstItem}. + * Enables the functionality that prevents {@link #ungroupedItems} from overflowing (wrapping to the next row) + * upon resize when there is little space available. Instead, the toolbar items are moved to the + * {@link #groupedItems} collection and displayed in a dropdown at the end of the row (which has its own nested toolbar). + * + * When called, the toolbar will automatically analyze the location of its {@link #ungroupedItems} and "group" + * them in the dropdown if necessary. It will also observe the browser window for size changes in + * the future and respond to them by grouping more items or reverting already grouped back, depending + * on the visual space available. * - * When called it will remove the last item from {@link #ungroupedItems} and move it to the - * {@link #groupedItems} collection. + * @private + */ + _enableGroupingOnResize() { + let previousWidth; + + // TODO: Consider debounce. + this.resizeObserver = getResizeObserver( ( [ entry ] ) => { + if ( !previousWidth || previousWidth !== entry.contentRect.width ) { + this._updateGrouping(); + + previousWidth = entry.contentRect.width; + } + } ); + + this.resizeObserver.observe( this.viewElement ); + + this._updateGrouping(); + } + + /** + * When called, it will remove the last item from {@link #ungroupedItems} and move it back + * to the {@link #groupedItems} collection. + * + * The opposite of {@link #_ungroupFirstItem}. * * @private */ @@ -705,11 +705,11 @@ class DynamicGrouping { } /** - * The opposite of {@link #_groupLastItem}. - * - * Moves the very first item from the toolbar belonging to {@link #groupedItems} back + * Moves the very first item belonging to {@link #groupedItems} back * to the {@link #ungroupedItems} collection. * + * The opposite of {@link #_groupLastItem}. + * * @private */ _ungroupFirstItem() { diff --git a/theme/components/toolbar/toolbar.css b/theme/components/toolbar/toolbar.css index b3cef7e6..054b7822 100644 --- a/theme/components/toolbar/toolbar.css +++ b/theme/components/toolbar/toolbar.css @@ -24,9 +24,10 @@ display: inline-block; /* - * A leading or trailing separator makes no sense (separates nothing from one side). - * Better hide it for better look. - */ + * A leading or trailing separator makes no sense (separates from nothing on one side). + * For instance, it can happen when toolbar items (also separators) are getting grouped and one by and + * moved to another toolbar in the drop–down. + */ &:first-child, &:last-child { display: none;