diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js
index 5e39b39d..d26a66c9 100644
--- a/src/toolbar/toolbarview.js
+++ b/src/toolbar/toolbarview.js
@@ -14,10 +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 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';
+import { createDropdown, addToolbarToDropdown } from '../dropdown/utils';
+import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import verticalDotsIcon from '@ckeditor/ckeditor5-core/theme/icons/three-vertical-dots.svg';
import '../../theme/components/toolbar/toolbar.css';
-import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
/**
* The toolbar view class.
@@ -32,13 +37,22 @@ export default class ToolbarView extends View {
* Also see {@link #render}.
*
* @param {module:utils/locale~Locale} locale The localization services instance.
+ * @param {module:ui/toolbar/toolbarview~ToolbarOptions} [options] Configuration options of the toolbar.
*/
- constructor( locale ) {
+ constructor( locale, options ) {
super( locale );
const bind = this.bindTemplate;
const t = this.t;
+ /**
+ * A reference to the options object passed to the constructor.
+ *
+ * @readonly
+ * @member {module:ui/toolbar/toolbarview~ToolbarOptions}
+ */
+ this.options = options || {};
+
/**
* Label used by assistive technologies to describe this toolbar element.
*
@@ -48,7 +62,7 @@ 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.).
*
* @readonly
* @member {module:ui/viewcollection~ViewCollection}
@@ -56,7 +70,7 @@ export default class ToolbarView extends View {
this.items = this.createCollection();
/**
- * Tracks information about DOM focus in the list.
+ * Tracks information about DOM focus in the toolbar.
*
* @readonly
* @member {module:utils/focustracker~FocusTracker}
@@ -64,7 +78,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}
@@ -72,30 +87,70 @@ export default class ToolbarView extends View {
this.keystrokes = new KeystrokeHandler();
/**
- * Controls the orientation of toolbar items.
+ * An additional CSS class added to the {@link #element}.
*
* @observable
- * @member {Boolean} #isVertical
+ * @member {String} #class
*/
- this.set( 'isVertical', false );
+ this.set( 'class' );
/**
- * An additional CSS class added to the {@link #element}.
+ * A (child) view containing {@link #items toolbar items}.
+ *
+ * @readonly
+ * @member {module:ui/toolbar/toolbarview~ItemsView}
+ */
+ this.itemsView = new ItemsView( locale );
+
+ /**
+ * A top–level collection aggregating building blocks of the toolbar.
+ *
+ * ┌───────────────── ToolbarView ─────────────────┐
+ * | ┌──────────────── #children ────────────────┐ |
+ * | | ┌──────────── #itemsView ───────────┐ | |
+ * | | | [ item1 ] [ item2 ] ... [ itemN ] | | |
+ * | | └──────────────────────────────────-┘ | |
+ * | └───────────────────────────────────────────┘ |
+ * └───────────────────────────────────────────────┘
+ *
+ * By default, it contains the {@link #itemsView} but it can be extended with additional
+ * UI elements when necessary.
+ *
+ * @readonly
+ * @member {module:ui/viewcollection~ViewCollection}
+ */
+ this.children = this.createCollection();
+ this.children.add( this.itemsView );
+
+ /**
+ * A collection of {@link #items} that take part in the focus cycling
+ * (i.e. navigation using the keyboard). Usually, it contains a subset of {@link #items} with
+ * some optional UI elements that also belong to the toolbar and should be focusable
+ * by the user.
+ *
+ * @readonly
+ * @member {module:ui/viewcollection~ViewCollection}
+ */
+ this.focusables = this.createCollection();
+
+ /**
+ * Controls the orientation of toolbar items. Only available when
+ * {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull dynamic items grouping}
+ * is **disabled**.
*
* @observable
- * @member {String} #class
+ * @member {Boolean} #isVertical
*/
- this.set( 'class' );
/**
- * Helps cycling over focusable {@link #items} in the toolbar.
+ * Helps cycling over {@link #focusables focusable items} in the toolbar.
*
* @readonly
* @protected
* @member {module:ui/focuscycler~FocusCycler}
*/
this._focusCycler = new FocusCycler( {
- focusables: this.items,
+ focusables: this.focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
@@ -113,20 +168,30 @@ export default class ToolbarView extends View {
class: [
'ck',
'ck-toolbar',
- bind.if( 'isVertical', 'ck-toolbar_vertical' ),
bind.to( 'class' )
],
role: 'toolbar',
'aria-label': bind.to( 'ariaLabel' )
},
- children: this.items,
+ children: this.children,
on: {
// https://github.com/ckeditor/ckeditor5-ui/issues/206
mousedown: preventDefault( this )
}
} );
+
+ /**
+ * An instance of the active toolbar behavior that shapes its look and functionality.
+ *
+ * See {@link module:ui/toolbar/toolbarview~ToolbarBehavior} to learn more.
+ *
+ * @protected
+ * @readonly
+ * @member {module:ui/toolbar/toolbarview~ToolbarBehavior}
+ */
+ this._behavior = this.options.shouldGroupWhenFull ? new DynamicGrouping( this ) : new StaticLayout( this );
}
/**
@@ -135,7 +200,7 @@ export default class ToolbarView extends View {
render() {
super.render();
- // Items added before rendering should be known to the #focusTracker.
+ // Children added before rendering should be known to the #focusTracker.
for ( const item of this.items ) {
this.focusTracker.add( item.element );
}
@@ -150,17 +215,28 @@ export default class ToolbarView extends View {
// Start listening for the keystrokes coming from #element.
this.keystrokes.listenTo( this.element );
+
+ this._behavior.render( this );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ destroy() {
+ this._behavior.destroy();
+
+ return super.destroy();
}
/**
- * Focuses the first focusable in {@link #items}.
+ * Focuses the first focusable in {@link #focusables}.
*/
focus() {
this._focusCycler.focusFirst();
}
/**
- * Focuses the last focusable in {@link #items}.
+ * Focuses the last focusable in {@link #focusables}.
*/
focusLast() {
this._focusCycler.focusLast();
@@ -204,3 +280,552 @@ export default class ToolbarView extends View {
}
}
+/**
+ * An inner block of the {@link module:ui/toolbar/toolbarview~ToolbarView} hosting its
+ * {@link module:ui/toolbar/toolbarview~ToolbarView#items}.
+ *
+ * @private
+ * @extends module:ui/view~View
+ */
+class ItemsView extends View {
+ /**
+ * @inheritDoc
+ */
+ constructor( locale ) {
+ super( locale );
+
+ /**
+ * Collection of the items (buttons, drop–downs, etc.).
+ *
+ * @readonly
+ * @member {module:ui/viewcollection~ViewCollection}
+ */
+ this.children = this.createCollection();
+
+ this.setTemplate( {
+ tag: 'div',
+ attributes: {
+ class: [
+ 'ck',
+ 'ck-toolbar__items'
+ ],
+ },
+ children: this.children
+ } );
+ }
+}
+
+/**
+ * A toolbar behavior that makes it static and unresponsive to the changes of the environment.
+ * At the same time, it also makes it possible to display a toolbar with a vertical layout
+ * using the {@link module:ui/toolbar/toolbarview~ToolbarView#isVertical} property.
+ *
+ * @private
+ * @implements module:ui/toolbar/toolbarview~ToolbarBehavior
+ */
+class StaticLayout {
+ /**
+ * Creates an instance of the {@link module:ui/toolbar/toolbarview~StaticLayout} toolbar
+ * behavior.
+ *
+ * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior
+ * is added to.
+ */
+ constructor( view ) {
+ const bind = view.bindTemplate;
+
+ // Static toolbar can be vertical when needed.
+ view.set( 'isVertical', false );
+
+ // 1:1 pass–through binding, all ToolbarView#items are visible.
+ view.itemsView.children.bindTo( view.items ).using( item => item );
+
+ // 1:1 pass–through binding, all ToolbarView#items are focusable.
+ view.focusables.bindTo( view.items ).using( item => item );
+
+ view.extendTemplate( {
+ attributes: {
+ class: [
+ // When vertical, the toolbar has an additional CSS class.
+ bind.if( 'isVertical', 'ck-toolbar_vertical' )
+ ]
+ }
+ } );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ render() {}
+
+ /**
+ * @inheritDoc
+ */
+ destroy() {}
+}
+
+/**
+ * A toolbar behavior that makes its items respond to the changes in the geometry.
+ *
+ * In a nutshell, it groups {@link module:ui/toolbar/toolbarview~ToolbarView#items}
+ * that do not fit into visually into a single row of the toolbar (due to limited space).
+ * Items that do not fit are aggregated in a dropdown displayed at the end of the toolbar.
+ *
+ * ┌──────────────────────────────────────── ToolbarView ──────────────────────────────────────────┐
+ * | ┌─────────────────────────────────────── #children ─────────────────────────────────────────┐ |
+ * | | ┌─────── #itemsView ────────┐ ┌──────────────────────┐ ┌── #groupedItemsDropdown ───┐ | |
+ * | | | #ungroupedItems | | ToolbarSeparatorView | | #groupedItems | | |
+ * | | └──────────────────────────-┘ └──────────────────────┘ └────────────────────────────┘ | |
+ * | | \---------- only when toolbar items overflow --------/ | |
+ * | └───────────────────────────────────────────────────────────────────────────────────────────┘ |
+ * └───────────────────────────────────────────────────────────────────────────────────────────────┘
+ *
+ * @private
+ * @implements module:ui/toolbar/toolbarview~ToolbarBehavior
+ */
+class DynamicGrouping {
+ /**
+ * Creates an instance of the {@link module:ui/toolbar/toolbarview~DynamicGrouping} toolbar
+ * behavior.
+ *
+ * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior
+ * is added to.
+ */
+ constructor( view ) {
+ /**
+ * Collection of toolbar children.
+ *
+ * @readonly
+ * @member {module:ui/viewcollection~ViewCollection}
+ */
+ this.viewChildren = view.children;
+
+ /**
+ * Collection of toolbar focusable elements.
+ *
+ * @readonly
+ * @member {module:ui/viewcollection~ViewCollection}
+ */
+ this.viewFocusables = view.focusables;
+
+ /**
+ * Collection of toolbar focusable elements.
+ *
+ * @readonly
+ * @member {module:ui/toolbar/toolbarview~ItemsView}
+ */
+ this.viewItemsView = view.itemsView;
+
+ /**
+ * Focus tracker of the toolbar.
+ *
+ * @readonly
+ * @member {module:utils/focustracker~FocusTracker}
+ */
+ this.viewFocusTracker = view.focusTracker;
+
+ /**
+ * Locale of the toolbar.
+ *
+ * @readonly
+ * @member {module:utils/locale~Locale}
+ */
+ this.viewLocale = view.locale;
+
+ /**
+ * Element of the toolbar.
+ *
+ * @readonly
+ * @member {HTMLElement} #viewElement
+ */
+
+ /**
+ * A subset of of toolbar {@link module:ui/toolbar/toolbarview~ToolbarView#items}.
+ * Aggregates items that fit into a single row of the toolbar and were not {@link #groupedItems grouped}
+ * into a {@link #groupedItemsDropdown dropdown}. Items of this collection are displayed in the
+ * {@link module:ui/toolbar/toolbarview~ToolbarView#itemsView}.
+ *
+ * When none of the {@link module:ui/toolbar/toolbarview~ToolbarView#items} were grouped, it
+ * matches the {@link module:ui/toolbar/toolbarview~ToolbarView#items} collection in size and order.
+ *
+ * @readonly
+ * @member {module:ui/viewcollection~ViewCollection}
+ */
+ this.ungroupedItems = view.createCollection();
+
+ /**
+ * A subset of of toolbar {@link module:ui/toolbar/toolbarview~ToolbarView#items}.
+ * A collection of the toolbar items that do not fit into a single row of the toolbar.
+ * Grouped items are displayed in a dedicated {@link #groupedItemsDropdown dropdown}.
+ *
+ * When none of the {@link module:ui/toolbar/toolbarview~ToolbarView#items} were grouped,
+ * this collection is empty.
+ *
+ * @readonly
+ * @member {module:ui/viewcollection~ViewCollection}
+ */
+ this.groupedItems = view.createCollection();
+
+ /**
+ * The dropdown that aggregates {@link #groupedItems grouped items} that do not fit into a single
+ * row of the toolbar. It is displayed on demand as the last of
+ * {@link module:ui/toolbar/toolbarview~ToolbarView#children toolbar children} and offers another
+ * (nested) toolbar which displays items that would normally overflow.
+ *
+ * @readonly
+ * @member {module:ui/dropdown/dropdownview~DropdownView}
+ */
+ this.groupedItemsDropdown = this._createGroupedItemsDropdown();
+
+ /**
+ * An instance of the resize observer that helps dynamically determine the geometry of the toolbar
+ * and manage items that do not fit into a single row.
+ *
+ * **Note:** Created in {@link #_enableGroupingOnResize}.
+ *
+ * @readonly
+ * @member {module:utils/dom/getresizeobserver~ResizeObserver}
+ */
+ this.resizeObserver = null;
+
+ /**
+ * A cached value of the horizontal padding style used by {@link #_updateGrouping}
+ * to manage the {@link module:ui/toolbar/toolbarview~ToolbarView#items} that do not fit into
+ * a single toolbar line. This value can be reused between updates because it is unlikely that
+ * the padding will change and re–using `Window.getComputedStyle()` is expensive.
+ *
+ * @readonly
+ * @member {Number}
+ */
+ this.cachedPadding = null;
+
+ // Only those items that were not grouped are visible to the user.
+ view.itemsView.children.bindTo( this.ungroupedItems ).using( item => item );
+
+ // Make sure all #items visible in the main space of the toolbar are "focuscycleable".
+ this.ungroupedItems.on( 'add', this._updateFocusCycleableItems.bind( this ) );
+ this.ungroupedItems.on( 'remove', this._updateFocusCycleableItems.bind( this ) );
+
+ // Make sure the #groupedItemsDropdown is also included in cycling when it appears.
+ view.children.on( 'add', this._updateFocusCycleableItems.bind( this ) );
+ view.children.on( 'remove', this._updateFocusCycleableItems.bind( this ) );
+
+ // ToolbarView#items is dynamic. When an item is added, it should be automatically
+ // represented in either grouped or ungrouped items at the right index.
+ // In other words #items == concat( #ungroupedItems, #groupedItems )
+ // (in length and order).
+ view.items.on( 'add', ( evt, item, index ) => {
+ if ( index > this.ungroupedItems.length ) {
+ this.groupedItems.add( item, index - this.ungroupedItems.length );
+ } else {
+ this.ungroupedItems.add( item, index );
+ }
+
+ // When a new ungrouped item joins in and lands in #ungroupedItems, there's a chance it causes
+ // the toolbar to overflow.
+ this._updateGrouping();
+ } );
+
+ // When an item is removed from ToolbarView#items, it should be automatically
+ // removed from either grouped or ungrouped items.
+ view.items.on( 'remove', ( evt, item, index ) => {
+ if ( index > this.ungroupedItems.length ) {
+ this.groupedItems.remove( item );
+ } else {
+ this.ungroupedItems.remove( item );
+ }
+
+ // Whether removed from grouped or ungrouped items, there is a chance
+ // some new space is available and we could do some ungrouping.
+ this._updateGrouping();
+ } );
+
+ view.extendTemplate( {
+ attributes: {
+ class: [
+ // To group items dynamically, the toolbar needs a dedicated CSS class.
+ 'ck-toolbar_grouping'
+ ]
+ }
+ } );
+ }
+
+ /**
+ * Enables dynamic items grouping based on the dimensions of the toolbar.
+ *
+ * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior
+ * is added to.
+ */
+ render( view ) {
+ this.viewElement = view.element;
+
+ this._enableGroupingOnResize();
+ }
+
+ /**
+ * Cleans up the internals used by this behavior.
+ */
+ destroy() {
+ // The dropdown may not be in ToolbarView#children at the moment of toolbar destruction
+ // so let's make sure it's actually destroyed along with the toolbar.
+ this.groupedItemsDropdown.destroy();
+
+ this.resizeObserver.disconnect();
+ }
+
+ /**
+ * When called, it will check if any of the {@link #ungroupedItems} do not fit into a single row of the toolbar,
+ * and it will move them to the {@link #groupedItems} when it happens.
+ *
+ * At the same time, it will also check if there is enough space in the toolbar for the first of the
+ * {@link #groupedItems} to be returned back to {@link #ungroupedItems} and still fit into a single row
+ * without the toolbar wrapping.
+ *
+ * @protected
+ */
+ _updateGrouping() {
+ // Do no grouping–related geometry analysis when the toolbar is detached from visible DOM,
+ // for instance before #render(), or after render but without a parent or a parent detached
+ // from DOM. DOMRects won't work anyway and there will be tons of warning in the console and
+ // nothing else.
+ if ( !this.viewElement.ownerDocument.body.contains( this.viewElement ) ) {
+ return;
+ }
+
+ let wereItemsGrouped;
+
+ // Group #items as long as some wrap to the next row. This will happen, for instance,
+ // when the toolbar is getting narrow and there is not enough space to display all items in
+ // a single row.
+ while ( this._areItemsOverflowing ) {
+ this._groupLastItem();
+
+ wereItemsGrouped = true;
+ }
+
+ // If none were grouped now but there were some items already grouped before,
+ // then, what the hell, maybe let's see if some of them can be ungrouped. This happens when,
+ // for instance, the toolbar is stretching and there's more space in it than before.
+ if ( !wereItemsGrouped && this.groupedItems.length ) {
+ // Ungroup items as long as none are overflowing or there are none to ungroup left.
+ while ( this.groupedItems.length && !this._areItemsOverflowing ) {
+ this._ungroupFirstItem();
+ }
+
+ // If the ungrouping ended up with some item wrapping to the next row,
+ // put it back to the group toolbar ("undo the last ungroup"). We don't know whether
+ // an item will wrap or not until we ungroup it (that's a DOM/CSS thing) so this
+ // clean–up is vital for the algorithm.
+ if ( this._areItemsOverflowing ) {
+ this._groupLastItem();
+ }
+ }
+ }
+
+ /**
+ * Returns `true` when {@link module:ui/toolbar/toolbarview~ToolbarView#element} children visually overflow,
+ * for instance if the toolbar is narrower than its members. `false` otherwise.
+ *
+ * @private
+ * @type {Boolean}
+ */
+ get _areItemsOverflowing() {
+ // An empty toolbar cannot overflow.
+ if ( !this.ungroupedItems.length ) {
+ return false;
+ }
+
+ const element = this.viewElement;
+ const uiLanguageDirection = this.viewLocale.uiLanguageDirection;
+ const lastChildRect = new Rect( element.lastChild );
+ const toolbarRect = new Rect( element );
+
+ if ( !this.cachedPadding ) {
+ const computedStyle = global.window.getComputedStyle( element );
+ const paddingProperty = uiLanguageDirection === 'ltr' ? 'paddingRight' : 'paddingLeft';
+
+ // parseInt() is essential because of quirky floating point numbers logic and DOM.
+ // If the padding turned out too big because of that, the grouped items dropdown would
+ // always look (from the Rect perspective) like it overflows (while it's not).
+ this.cachedPadding = Number.parseInt( computedStyle[ paddingProperty ] );
+ }
+
+ if ( uiLanguageDirection === 'ltr' ) {
+ return lastChildRect.right > toolbarRect.right - this.cachedPadding;
+ } else {
+ return lastChildRect.left < toolbarRect.left + this.cachedPadding;
+ }
+ }
+
+ /**
+ * Enables the functionality that prevents {@link #ungroupedItems} from overflowing (wrapping to the next row)
+ * upon resize when there is little space available. Instead, the toolbar items are moved to the
+ * {@link #groupedItems} collection and displayed in a dropdown at the end of the row (which has its own nested toolbar).
+ *
+ * When called, the toolbar will automatically analyze the location of its {@link #ungroupedItems} and "group"
+ * them in the dropdown if necessary. It will also observe the browser window for size changes in
+ * the future and respond to them by grouping more items or reverting already grouped back, depending
+ * on the visual space available.
+ *
+ * @private
+ */
+ _enableGroupingOnResize() {
+ let previousWidth;
+
+ // TODO: Consider debounce.
+ this.resizeObserver = getResizeObserver( ( [ entry ] ) => {
+ if ( !previousWidth || previousWidth !== entry.contentRect.width ) {
+ this._updateGrouping();
+
+ previousWidth = entry.contentRect.width;
+ }
+ } );
+
+ this.resizeObserver.observe( this.viewElement );
+
+ this._updateGrouping();
+ }
+
+ /**
+ * When called, it will remove the last item from {@link #ungroupedItems} and move it back
+ * to the {@link #groupedItems} collection.
+ *
+ * The opposite of {@link #_ungroupFirstItem}.
+ *
+ * @private
+ */
+ _groupLastItem() {
+ if ( !this.groupedItems.length ) {
+ this.viewChildren.add( new ToolbarSeparatorView() );
+ this.viewChildren.add( this.groupedItemsDropdown );
+ this.viewFocusTracker.add( this.groupedItemsDropdown.element );
+ }
+
+ this.groupedItems.add( this.ungroupedItems.remove( this.ungroupedItems.last ), 0 );
+ }
+
+ /**
+ * Moves the very first item belonging to {@link #groupedItems} back
+ * to the {@link #ungroupedItems} collection.
+ *
+ * The opposite of {@link #_groupLastItem}.
+ *
+ * @private
+ */
+ _ungroupFirstItem() {
+ this.ungroupedItems.add( this.groupedItems.remove( this.groupedItems.first ) );
+
+ if ( !this.groupedItems.length ) {
+ this.viewChildren.remove( this.groupedItemsDropdown );
+ this.viewChildren.remove( this.viewChildren.last );
+ this.viewFocusTracker.remove( this.groupedItemsDropdown.element );
+ }
+ }
+
+ /**
+ * Creates the {@link #groupedItemsDropdown} that hosts the members of the {@link #groupedItems}
+ * collection when there is not enough space in the toolbar to display all items in a single row.
+ *
+ * @private
+ * @returns {module:ui/dropdown/dropdownview~DropdownView}
+ */
+ _createGroupedItemsDropdown() {
+ const locale = this.viewLocale;
+ const t = locale.t;
+ const dropdown = createDropdown( locale );
+
+ dropdown.class = 'ck-toolbar__grouped-dropdown';
+ addToolbarToDropdown( dropdown, [] );
+
+ dropdown.buttonView.set( {
+ label: t( 'Show more items' ),
+ tooltip: true,
+ icon: verticalDotsIcon
+ } );
+
+ // 1:1 pass–through binding.
+ dropdown.toolbarView.items.bindTo( this.groupedItems ).using( item => item );
+
+ return dropdown;
+ }
+
+ /**
+ * A method that updates the {@link module:ui/toolbar/toolbarview~ToolbarView#focusables focus–cycleable items}
+ * collection so it represents the up–to–date state of the UI from the perspective of the user.
+ *
+ * For instance, the {@link #groupedItemsDropdown} can show up and hide but when it is visible,
+ * it must be subject to focus cycling in the toolbar.
+ *
+ * See the {@link module:ui/toolbar/toolbarview~ToolbarView#focusables collection} documentation
+ * to learn more about the purpose of this method.
+ *
+ * @private
+ */
+ _updateFocusCycleableItems() {
+ this.viewFocusables.clear();
+
+ this.ungroupedItems.map( item => {
+ this.viewFocusables.add( item );
+ } );
+
+ if ( this.groupedItems.length ) {
+ this.viewFocusables.add( this.groupedItemsDropdown );
+ }
+ }
+}
+
+/**
+ * Options passed to the {@link module:ui/toolbar/toolbarview~ToolbarView#constructor} of the toolbar.
+ *
+ * @interface module:ui/toolbar/toolbarview~ToolbarOptions
+ */
+
+/**
+ * When set `true`, the toolbar will automatically group {@link module:ui/toolbar/toolbarview~ToolbarView#items} that
+ * would normally wrap to the next line when there is not enough space to display them in a single row, for
+ * instance, if the parent container of the toolbar is narrow.
+ *
+ * @member {Boolean} module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull
+ */
+
+/**
+ * A class interface defining a behavior of the {@link module:ui/toolbar/toolbarview~ToolbarView}.
+ *
+ * Toolbar behaviors extend its look and functionality and have an impact on the
+ * {@link module:ui/toolbar/toolbarview~ToolbarView#element} template or
+ * {@link module:ui/toolbar/toolbarview~ToolbarView#render rendering}. They can be enabled
+ * conditionally, e.g. depending on the configuration of the toolbar.
+ *
+ * @private
+ * @interface module:ui/toolbar/toolbarview~ToolbarBehavior
+ */
+
+/**
+ * Creates a new toolbar behavior instance.
+ *
+ * The instance is created in the {@link module:ui/toolbar/toolbarview~ToolbarView#constructor} of the toolbar.
+ * This is the right place to extend the {@link module:ui/toolbar/toolbarview~ToolbarView#template} of
+ * the toolbar, define extra toolbar properties, etc..
+ *
+ * @method #constructor
+ * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar this behavior is added to.
+ */
+
+/**
+ * A method called after the toolbar has been {@link module:ui/toolbar/toolbarview~ToolbarView#render rendered}.
+ * E.g. it can be used to customize the behavior of the toolbar when its {@link module:ui/toolbar/toolbarview~ToolbarView#element}
+ * is available.
+ *
+ * @readonly
+ * @member {Function} #render
+ * @param {module:ui/toolbar/toolbarview~ToolbarView} view An instance of the toolbar being rendered.
+ */
+
+/**
+ * A method called after the toolbar has been {@link module:ui/toolbar/toolbarview~ToolbarView#destroy destroyed}.
+ * It allows cleaning up after the toolbar behavior, for instance, this is the right place to detach
+ * event listeners, free up references, etc..
+ *
+ * @readonly
+ * @member {Function} #destroy
+ */
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 with RTL UI
+
+
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.
+
diff --git a/tests/toolbar/toolbarview.js b/tests/toolbar/toolbarview.js
index 7286d930..52725e23 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';
@@ -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';
@@ -52,11 +53,25 @@ 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#children collection', () => {
+ it( 'should create view#items collection', () => {
expect( view.items ).to.be.instanceOf( ViewCollection );
} );
@@ -68,9 +83,21 @@ describe( 'ToolbarView', () => {
expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
} );
+ it( 'should create view#itemsView', () => {
+ expect( view.itemsView ).to.be.instanceOf( View );
+ } );
+
+ it( 'should create view#children collection', () => {
+ expect( view.children ).to.be.instanceOf( ViewCollection );
+ } );
+
it( 'creates #_focusCycler instance', () => {
expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
} );
+
+ it( 'creates #_behavior', () => {
+ expect( view._behavior ).to.be.an( 'object' );
+ } );
} );
describe( 'template', () => {
@@ -79,6 +106,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' );
@@ -93,6 +126,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', () => {
@@ -101,6 +136,8 @@ describe( 'ToolbarView', () => {
view.render();
expect( view.element.getAttribute( 'aria-label' ) ).to.equal( 'Pasek narzędzi edytora' );
+
+ view.destroy();
} );
} );
@@ -117,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;
@@ -150,6 +179,7 @@ describe( 'ToolbarView', () => {
sinon.assert.notCalled( spyAdd );
view.render();
+
sinon.assert.calledTwice( spyAdd );
view.items.remove( 1 );
@@ -171,11 +201,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 );
@@ -198,20 +224,15 @@ describe( 'ToolbarView', () => {
view.focusTracker.isFocused = true;
view.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() );
@@ -221,18 +242,12 @@ describe( 'ToolbarView', () => {
view.focusTracker.isFocused = true;
view.focusTracker.focusedElement = view.items.get( 2 ).element;
- const spy = sinon.spy( view.items.get( 0 ), 'focus' );
-
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 );
@@ -255,20 +270,15 @@ describe( 'ToolbarView', () => {
view.focusTracker.isFocused = true;
view.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() );
@@ -278,16 +288,42 @@ describe( 'ToolbarView', () => {
view.focusTracker.isFocused = true;
view.focusTracker.focusedElement = view.items.get( 0 ).element;
- const spy = sinon.spy( view.items.get( 2 ), 'focus' );
-
view.keystrokes.press( keyEvtData );
- sinon.assert.calledOnce( spy );
+ sinon.assert.calledOnce( view.items.get( 2 ).focus );
} );
} );
+
+ it( 'calls _behavior#render()', () => {
+ const view = new ToolbarView( locale );
+ sinon.spy( view._behavior, 'render' );
+
+ view.render();
+ sinon.assert.calledOnce( view._behavior.render );
+ sinon.assert.calledWithExactly( view._behavior.render, view );
+
+ view.destroy();
+ } );
+ } );
+
+ describe( 'destroy()', () => {
+ it( 'destroys the feature', () => {
+ sinon.spy( view._behavior, 'destroy' );
+
+ view.destroy();
+
+ sinon.assert.calledOnce( view._behavior.destroy );
+ } );
+
+ it( 'calls _behavior#destroy()', () => {
+ sinon.spy( view._behavior, 'destroy' );
+
+ view.destroy();
+ sinon.assert.calledOnce( view._behavior.destroy );
+ } );
} );
describe( 'focus()', () => {
- 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 +332,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 +350,9 @@ 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 );
} );
} );
@@ -361,19 +395,516 @@ describe( 'ToolbarView', () => {
);
} );
} );
+
+ describe( 'toolbar with static items', () => {
+ describe( 'constructor()', () => {
+ it( 'should set view#isVertical', () => {
+ expect( view.isVertical ).to.be.false;
+ } );
+
+ it( 'binds itemsView#children to #items', () => {
+ const itemA = focusable();
+ const itemB = focusable();
+ const itemC = focusable();
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+ view.items.add( itemC );
+
+ expect( view.itemsView.children.map( i => i ) ).to.have.ordered.members( [ itemA, itemB, itemC ] );
+ } );
+
+ it( 'binds #focusables to #items', () => {
+ const itemA = focusable();
+ const itemB = focusable();
+ const itemC = focusable();
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+ view.items.add( itemC );
+
+ expect( view.focusables.map( i => i ) ).to.have.ordered.members( [ itemA, itemB, itemC ] );
+ } );
+ } );
+
+ describe( 'element bindings', () => {
+ describe( 'class', () => {
+ it( 'reacts on view#isVertical', () => {
+ view.isVertical = false;
+ expect( view.element.classList.contains( 'ck-toolbar_vertical' ) ).to.be.false;
+
+ view.isVertical = true;
+ expect( view.element.classList.contains( 'ck-toolbar_vertical' ) ).to.be.true;
+ } );
+ } );
+ } );
+ } );
+
+ describe( 'toolbar with a dynamic item grouping', () => {
+ let locale, view, groupedItems, ungroupedItems, groupedItemsDropdown;
+
+ beforeEach( () => {
+ locale = new Locale();
+ view = new ToolbarView( locale, {
+ shouldGroupWhenFull: true
+ } );
+ view.render();
+ view.element.style.width = '200px';
+ document.body.appendChild( view.element );
+
+ groupedItems = view._behavior.groupedItems;
+ ungroupedItems = view._behavior.ungroupedItems;
+ groupedItemsDropdown = view._behavior.groupedItemsDropdown;
+ } );
+
+ afterEach( () => {
+ sinon.restore();
+ view.element.remove();
+ view.destroy();
+ } );
+
+ describe( 'constructor()', () => {
+ it( 'extends the template with the CSS class', () => {
+ expect( view.element.classList.contains( 'ck-toolbar_grouping' ) ).to.be.true;
+ } );
+
+ it( 'updates the UI as new #items are added', () => {
+ sinon.spy( view._behavior, '_updateGrouping' );
+
+ const itemA = focusable();
+ const itemB = focusable();
+ const itemC = focusable();
+ const itemD = focusable();
+
+ view.element.style.width = '200px';
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+
+ sinon.assert.calledTwice( view._behavior._updateGrouping );
+
+ expect( ungroupedItems ).to.have.length( 2 );
+ expect( groupedItems ).to.have.length( 0 );
+
+ view.items.add( itemC );
+
+ // The dropdown took some extra space.
+ expect( ungroupedItems ).to.have.length( 1 );
+ expect( groupedItems ).to.have.length( 2 );
+
+ view.items.add( itemD, 2 );
+
+ expect( ungroupedItems ).to.have.length( 1 );
+ expect( groupedItems ).to.have.length( 3 );
+
+ expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA ] );
+ expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemD, itemC ] );
+ } );
+
+ it( 'updates the UI as #items are removed', () => {
+ const itemA = focusable();
+ const itemB = focusable();
+ const itemC = focusable();
+ const itemD = focusable();
+
+ view.element.style.width = '200px';
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+ view.items.add( itemC );
+ view.items.add( itemD );
+
+ sinon.spy( view._behavior, '_updateGrouping' );
+ view.items.remove( 2 );
+
+ expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA ] );
+ expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemD ] );
+
+ sinon.assert.calledOnce( view._behavior._updateGrouping );
+
+ view.items.remove( 0 );
+ sinon.assert.calledTwice( view._behavior._updateGrouping );
+
+ expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemD ] );
+ } );
+ } );
+
+ it( 'groups items that overflow into the dropdown', () => {
+ const itemA = focusable();
+ const itemB = focusable();
+ const itemC = focusable();
+ const itemD = focusable();
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+ view.items.add( itemC );
+ view.items.add( itemD );
+
+ expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA ] );
+ expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemC, itemD ] );
+ expect( view.children ).to.have.length( 3 );
+ expect( view.children.get( 0 ) ).to.equal( view.itemsView );
+ expect( view.children.get( 1 ) ).to.be.instanceOf( ToolbarSeparatorView );
+ expect( view.children.get( 2 ) ).to.equal( groupedItemsDropdown );
+ } );
+
+ it( 'ungroups items if there is enough space to display them (all)', () => {
+ const itemA = focusable();
+ const itemB = focusable();
+ const itemC = focusable();
+ const itemD = focusable();
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+ view.items.add( itemC );
+ view.items.add( itemD );
+
+ expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA ] );
+ expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemC, itemD ] );
+
+ view.element.style.width = '350px';
+
+ // Some grouped items cannot be ungrouped because there is not enough space and they will
+ // land back in #_behavior.groupedItems after an attempt was made.
+ view._behavior._updateGrouping();
+ expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA, itemB, itemC ] );
+ expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemD ] );
+ } );
+
+ it( 'ungroups items if there is enough space to display them (some)', () => {
+ const itemA = focusable();
+ const itemB = focusable();
+ const itemC = focusable();
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+ view.items.add( itemC );
+
+ expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA ] );
+ expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemC ] );
+
+ view.element.style.width = '350px';
+
+ // All grouped items will be ungrouped because they fit just alright in the main space.
+ view._behavior._updateGrouping();
+ expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemA, itemB, itemC ] );
+ expect( groupedItems ).to.have.length( 0 );
+ expect( view.children ).to.have.length( 1 );
+ expect( view.children.get( 0 ) ).to.equal( view.itemsView );
+ } );
+
+ describe( 'render()', () => {
+ it( 'starts observing toolbar resize immediatelly after render', () => {
+ function FakeResizeObserver( callback ) {
+ this.callback = callback;
+ }
+
+ FakeResizeObserver.prototype.observe = sinon.spy();
+ FakeResizeObserver.prototype.disconnect = sinon.spy();
+
+ testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( FakeResizeObserver );
+
+ const view = new ToolbarView( locale, {
+ shouldGroupWhenFull: true
+ } );
+
+ view.render();
+
+ sinon.assert.calledOnce( view._behavior.resizeObserver.observe );
+ sinon.assert.calledWithExactly( view._behavior.resizeObserver.observe, view.element );
+
+ view.destroy();
+ } );
+
+ it( 'updates the UI when the toolbar is being resized (expanding)', done => {
+ view.element.style.width = '200px';
+
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+
+ expect( ungroupedItems ).to.have.length( 1 );
+ expect( groupedItems ).to.have.length( 4 );
+
+ view.element.style.width = '500px';
+
+ setTimeout( () => {
+ expect( ungroupedItems ).to.have.length( 5 );
+ expect( groupedItems ).to.have.length( 0 );
+
+ done();
+ }, 100 );
+ } );
+
+ it( 'updates the UI when the toolbar is being resized (narrowing)', done => {
+ view.element.style.width = '500px';
+
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+
+ expect( ungroupedItems ).to.have.length( 5 );
+ expect( groupedItems ).to.have.length( 0 );
+
+ view.element.style.width = '200px';
+
+ setTimeout( () => {
+ expect( ungroupedItems ).to.have.length( 1 );
+ expect( groupedItems ).to.have.length( 4 );
+
+ done();
+ }, 100 );
+ } );
+
+ it( 'does not react to changes in height', done => {
+ view.element.style.width = '500px';
+ view.element.style.height = '200px';
+
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+
+ sinon.spy( view._behavior, '_updateGrouping' );
+ view.element.style.width = '500px';
+
+ setTimeout( () => {
+ sinon.assert.calledOnce( view._behavior._updateGrouping );
+ view.element.style.height = '500px';
+
+ setTimeout( () => {
+ sinon.assert.calledOnce( view._behavior._updateGrouping );
+ done();
+ }, 100 );
+ }, 100 );
+ } );
+
+ it( 'updates the state of grouped items upon resize', () => {
+ function FakeResizeObserver( callback ) {
+ this.callback = callback;
+ }
+
+ FakeResizeObserver.prototype.observe = sinon.spy();
+ FakeResizeObserver.prototype.disconnect = sinon.spy();
+
+ testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( FakeResizeObserver );
+
+ const view = new ToolbarView( locale, {
+ shouldGroupWhenFull: true
+ } );
+
+ testUtils.sinon.spy( view._behavior, '_updateGrouping' );
+
+ view.render();
+
+ view._behavior.resizeObserver.callback( [
+ { contentRect: { width: 42 } }
+ ] );
+
+ sinon.assert.calledTwice( view._behavior._updateGrouping );
+
+ view.destroy();
+ } );
+ } );
+
+ describe( 'destroy()', () => {
+ it( 'destroys the #groupedItemsDropdown', () => {
+ view.element.style.width = '200px';
+
+ const itemA = focusable();
+ const itemB = focusable();
+ const itemC = focusable();
+ const itemD = focusable();
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+ view.items.add( itemC );
+ view.items.add( itemD );
+
+ sinon.spy( groupedItemsDropdown, 'destroy' );
+
+ view.element.style.width = '500px';
+
+ // The dropdown hides; it does not belong to any collection but it still exist.
+ view._behavior._updateGrouping();
+
+ view.destroy();
+ sinon.assert.calledOnce( groupedItemsDropdown.destroy );
+ } );
+
+ it( 'disconnects the #resizeObserver', () => {
+ view.element.style.width = '200px';
+
+ const itemA = focusable();
+ const itemB = focusable();
+ const itemC = focusable();
+ const itemD = focusable();
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+ view.items.add( itemC );
+ view.items.add( itemD );
+
+ sinon.spy( view._behavior.resizeObserver, 'disconnect' );
+
+ view.destroy();
+ sinon.assert.calledOnce( view._behavior.resizeObserver.disconnect );
+ } );
+ } );
+
+ describe( 'dropdown with grouped items', () => {
+ it( 'has proper DOM structure', () => {
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+
+ expect( view.children.has( groupedItemsDropdown ) ).to.be.true;
+ expect( groupedItemsDropdown.element.classList.contains( 'ck-toolbar__grouped-dropdown' ) );
+ expect( groupedItemsDropdown.buttonView.label ).to.equal( 'Show more items' );
+ } );
+
+ it( 'shares its toolbarView#items with grouped items', () => {
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+
+ expect( groupedItemsDropdown.toolbarView.items.map( i => i ) )
+ .to.have.ordered.members( groupedItems.map( i => i ) );
+ } );
+ } );
+
+ describe( 'item overflow checking logic', () => {
+ it( 'considers the right padding of the toolbar (LTR UI)', () => {
+ view.class = 'ck-reset_all';
+ view.element.style.width = '210px';
+ view.element.style.paddingLeft = '0px';
+ view.element.style.paddingRight = '20px';
+
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+
+ expect( view._behavior.groupedItems ).to.have.length( 1 );
+ } );
+
+ it( 'considers the left padding of the toolbar (RTL UI)', () => {
+ const locale = new Locale( { uiLanguage: 'ar' } );
+ const view = new ToolbarView( locale, {
+ shouldGroupWhenFull: true
+ } );
+
+ view.extendTemplate( {
+ attributes: {
+ dir: locale.uiLanguageDirection
+ }
+ } );
+
+ view.render();
+ document.body.appendChild( view.element );
+
+ view.class = 'ck-reset_all';
+ view.element.style.width = '210px';
+ view.element.style.paddingLeft = '20px';
+ view.element.style.paddingRight = '0px';
+
+ view.items.add( focusable() );
+ view.items.add( focusable() );
+
+ expect( view._behavior.groupedItems ).to.have.length( 1 );
+
+ view.destroy();
+ view.element.remove();
+ } );
+ } );
+
+ describe( 'focus management', () => {
+ it( '#focus() focuses the dropdown when it is the only focusable', () => {
+ sinon.spy( groupedItemsDropdown, 'focus' );
+ view.element.style.width = '10px';
+
+ const itemA = focusable();
+ const itemB = focusable();
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+
+ expect( view.focusables.map( i => i ) ).to.have.ordered.members( [ groupedItemsDropdown ] );
+
+ view.focus();
+ sinon.assert.calledOnce( groupedItemsDropdown.focus );
+ } );
+
+ it( '#focusLast() focuses the dropdown when present', () => {
+ sinon.spy( groupedItemsDropdown, 'focus' );
+ view.element.style.width = '200px';
+
+ const itemA = focusable();
+ const itemB = focusable();
+ const itemC = focusable();
+
+ view.items.add( itemA );
+ view.items.add( itemB );
+ view.items.add( itemC );
+
+ expect( view.focusables.map( i => i ) ).to.have.ordered.members( [ itemA, groupedItemsDropdown ] );
+
+ view.focusLast();
+
+ sinon.assert.calledOnce( groupedItemsDropdown.focus );
+
+ view.element.remove();
+ } );
+ } );
+ } );
} );
function focusable() {
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',
+ background: 'rgba(255,0,0,.3)'
+ }
+ },
+ children: [
+ {
+ text: bind.to( 'label' )
+ }
+ ]
+ } );
return view;
}
@@ -388,3 +919,11 @@ function namedFactory( name ) {
return view;
};
}
+
+function getArrowKeyData( arrow ) {
+ return {
+ keyCode: keyCodes[ arrow ],
+ preventDefault: sinon.spy(),
+ stopPropagation: sinon.spy()
+ };
+}
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 ddf26e55..054b7822 100644
--- a/theme/components/toolbar/toolbar.css
+++ b/theme/components/toolbar/toolbar.css
@@ -9,23 +9,46 @@
@mixin ck-unselectable;
display: flex;
- flex-flow: row wrap;
+ flex-flow: row nowrap;
align-items: center;
- &.ck-toolbar_vertical {
- flex-direction: column;
+ & > .ck-toolbar__items {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ flex-grow: 1;
+
}
- &.ck-toolbar_floating {
+ & .ck.ck-toolbar__separator {
+ display: inline-block;
+
+ /*
+ * A leading or trailing separator makes no sense (separates from nothing on one side).
+ * For instance, it can happen when toolbar items (also separators) are getting grouped and one by and
+ * moved to another toolbar in the drop–down.
+ */
+ &:first-child,
+ &:last-child {
+ display: none;
+ }
+ }
+
+ &.ck-toolbar_grouping > .ck-toolbar__items {
flex-wrap: nowrap;
}
-}
-.ck.ck-toolbar__separator {
- display: inline-block;
-}
+ &.ck-toolbar_vertical > .ck-toolbar__items {
+ flex-direction: column;
+ }
+
+ &.ck-toolbar_floating > .ck-toolbar__items {
+ flex-wrap: nowrap;
+ }
-.ck.ck-toolbar__newline {
- display: block;
- width: 100%;
+ & > .ck.ck-toolbar__grouped-dropdown {
+ & > .ck-dropdown__button .ck-dropdown__arrow {
+ display: none;
+ }
+ }
}