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 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. + 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; + } + } }