diff --git a/packages/ckeditor5-font/src/ui/colortableview.js b/packages/ckeditor5-font/src/ui/colortableview.js index ba1f407b360..61de2ce78a2 100644 --- a/packages/ckeditor5-font/src/ui/colortableview.js +++ b/packages/ckeditor5-font/src/ui/colortableview.js @@ -8,7 +8,16 @@ */ import { icons } from 'ckeditor5/src/core'; -import { ButtonView, ColorGridView, ColorTileView, FocusCycler, LabelView, Template, View } from 'ckeditor5/src/ui'; +import { + ButtonView, + ColorGridView, + ColorTileView, + FocusCycler, + LabelView, + Template, + View, + ViewCollection +} from 'ckeditor5/src/ui'; import { FocusTracker, KeystrokeHandler } from 'ckeditor5/src/utils'; import DocumentColorCollection from '../documentcolorcollection'; @@ -109,6 +118,15 @@ export default class ColorTableView extends View { */ this.documentColorsCount = documentColorsCount; + /** + * A collection of views that can be focused in the view. + * + * @readonly + * @protected + * @member {module:ui/viewcollection~ViewCollection} + */ + this._focusables = new ViewCollection(); + /** * Preserves the reference to {@link module:ui/colorgrid/colorgrid~ColorGridView} used to create * the default (static) color set. @@ -137,15 +155,15 @@ export default class ColorTableView extends View { * @member {module:ui/focuscycler~FocusCycler} */ this._focusCycler = new FocusCycler( { - focusables: this.items, + focusables: this._focusables, focusTracker: this.focusTracker, keystrokeHandler: this.keystrokes, actions: { - // Navigate list items backwards using the Arrow Up key. - focusPrevious: 'arrowup', + // Navigate list items backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', - // Navigate list items forwards using the Arrow Down key. - focusNext: 'arrowdown' + // Navigate list items forwards using the Tab key. + focusNext: 'tab' } } ); @@ -169,7 +187,7 @@ export default class ColorTableView extends View { children: this.items } ); - this.items.add( this._removeColorButton() ); + this.items.add( this._createRemoveColorButton() ); } /** @@ -226,11 +244,6 @@ export default class ColorTableView extends View { render() { super.render(); - // Items added before rendering should be known to the #focusTracker. - for ( const item of this.items ) { - this.focusTracker.add( item.element ); - } - // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); } @@ -256,6 +269,8 @@ export default class ColorTableView extends View { this.staticColorsGrid = this._createStaticColorsGrid(); this.items.add( this.staticColorsGrid ); + this.focusTracker.add( this.staticColorsGrid.element ); + this._focusables.add( this.staticColorsGrid ); if ( this.documentColorsCount ) { // Create a label for document colors. @@ -273,7 +288,10 @@ export default class ColorTableView extends View { } ); this.items.add( label ); this.documentColorsGrid = this._createDocumentColorsGrid(); + this.items.add( this.documentColorsGrid ); + this.focusTracker.add( this.documentColorsGrid.element ); + this._focusables.add( this.documentColorsGrid ); } } @@ -297,7 +315,7 @@ export default class ColorTableView extends View { * @private * @returns {module:ui/button/buttonview~ButtonView} */ - _removeColorButton() { + _createRemoveColorButton() { const buttonView = new ButtonView(); buttonView.set( { @@ -312,6 +330,11 @@ export default class ColorTableView extends View { this.fire( 'execute', { value: null } ); } ); + buttonView.render(); + + this.focusTracker.add( buttonView.element ); + this._focusables.add( buttonView ); + return buttonView; } diff --git a/packages/ckeditor5-font/tests/ui/colortableview.js b/packages/ckeditor5-font/tests/ui/colortableview.js index 1915ca3173b..8361e0a20dd 100644 --- a/packages/ckeditor5-font/tests/ui/colortableview.js +++ b/packages/ckeditor5-font/tests/ui/colortableview.js @@ -5,20 +5,24 @@ /* globals document,Event */ -import TestColorPlugin from '../_utils/testcolorplugin'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import ColorTableView from './../../src/ui/colortableview'; +import ColorTileView from '@ckeditor/ckeditor5-ui/src/colorgrid/colortileview'; + import Collection from '@ckeditor/ckeditor5-utils/src/collection'; import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler'; -import removeButtonIcon from '@ckeditor/ckeditor5-core/theme/icons/eraser.svg'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + +import TestColorPlugin from '../_utils/testcolorplugin'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; -import ColorTileView from '@ckeditor/ckeditor5-ui/src/colorgrid/colortileview'; -import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; -import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + +import removeButtonIcon from '@ckeditor/ckeditor5-core/theme/icons/eraser.svg'; describe( 'ColorTableView', () => { let locale, colorTableView; @@ -79,12 +83,16 @@ describe( 'ColorTableView', () => { documentColorsLabel: 'Document colors', documentColorsCount: 4 } ); - colorTableView.appendGrids(); + // Grids rendering is deferred (#6192) therefore render happens before appending grids. colorTableView.render(); + colorTableView.appendGrids(); + + document.body.appendChild( colorTableView.element ); } ); afterEach( () => { colorTableView.destroy(); + colorTableView.element.remove(); } ); testUtils.createSinonSandbox(); @@ -163,8 +171,8 @@ describe( 'ColorTableView', () => { } ); } ); - describe( 'focus tracker', () => { - it( 'should focus first child of colorTableView in DOM', () => { + describe( 'focus tracking', () => { + it( 'should focus first child of colorTableView in DOM on focus()', () => { const spy = sinon.spy( colorTableView._focusCycler, 'focusFirst' ); colorTableView.focus(); @@ -172,13 +180,65 @@ describe( 'ColorTableView', () => { sinon.assert.calledOnce( spy ); } ); - it( 'should focuses the last child of colorTableView in DOM', () => { + it( 'should focus the last child of colorTableView in DOM on focusLast()', () => { const spy = sinon.spy( colorTableView._focusCycler, 'focusLast' ); colorTableView.focusLast(); sinon.assert.calledOnce( spy ); } ); + + describe( 'navigation across table controls using Tab and Shift+Tab keys', () => { + beforeEach( () => { + // Needed for the document colors grid to show up in the view. + colorTableView.documentColors.add( { + color: '#000000', + label: 'Black', + options: { + hasBorder: false + } + } ); + } ); + + it( 'should navigate forwards using the Tab key', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the remove color button is focused. + colorTableView.focusTracker.isFocused = true; + colorTableView.focusTracker.focusedElement = colorTableView.items.get( 0 ).element; + + const spy = sinon.spy( colorTableView.staticColorsGrid, 'focus' ); + + colorTableView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'should navigate backwards using the Shift+Tab key', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the remove color button is focused. + colorTableView.focusTracker.isFocused = true; + colorTableView.focusTracker.focusedElement = colorTableView.items.get( 0 ).element; + + const spy = sinon.spy( colorTableView.documentColorsGrid, 'focus' ); + + colorTableView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + } ); } ); describe( 'remove color button', () => { @@ -382,8 +442,9 @@ describe( 'ColorTableView', () => { removeButtonLabel: 'Remove color', documentColorsCount: 0 } ); - colorTableView.appendGrids(); + // Grids rendering is deferred (#6192) therefore render happens before appending grids. colorTableView.render(); + colorTableView.appendGrids(); } ); afterEach( () => { diff --git a/packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.js b/packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.js index d2339ed5a71..883763167d6 100644 --- a/packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.js +++ b/packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.js @@ -13,7 +13,8 @@ import { FocusCycler, SwitchButtonView, LabeledFieldView, - createLabeledInputNumber + createLabeledInputNumber, + addKeyboardHandlingForGrid } from 'ckeditor5/src/ui'; import { FocusTracker, @@ -178,19 +179,25 @@ export default class ListPropertiesView extends View { super.render(); if ( this.stylesView ) { - for ( const styleButtonView of this.stylesView.children ) { - // Register the view as focusable. - this.focusables.add( styleButtonView ); - - // Register the view in the focus tracker. - this.focusTracker.add( styleButtonView.element ); - } + this.focusables.add( this.stylesView ); + this.focusTracker.add( this.stylesView.element ); // Register the collapsible toggle button to the focus system. if ( this.startIndexFieldView || this.reversedSwitchButtonView ) { this.focusables.add( this.children.last.buttonView ); this.focusTracker.add( this.children.last.buttonView.element ); } + + for ( const item of this.stylesView.children ) { + this.stylesView.focusTracker.add( item.element ); + } + + addKeyboardHandlingForGrid( { + keystrokeHandler: this.stylesView.keystrokes, + focusTracker: this.stylesView.focusTracker, + gridItems: this.stylesView.children, + numberOfColumns: 4 + } ); } if ( this.startIndexFieldView ) { @@ -276,6 +283,17 @@ export default class ListPropertiesView extends View { stylesView.children.delegate( 'execute' ).to( this ); + stylesView.focus = function() { + this.children.first.focus(); + }; + + stylesView.focusTracker = new FocusTracker(); + stylesView.keystrokes = new KeystrokeHandler(); + + stylesView.render(); + + stylesView.keystrokes.listenTo( stylesView.element ); + return stylesView; } diff --git a/packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js b/packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js index 6d7bb6f433e..2e7491e80b2 100644 --- a/packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js +++ b/packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js @@ -35,6 +35,9 @@ describe( 'ListPropertiesView', () => { reversed: true }, styleButtonViews: [ + new ButtonView( locale ), + new ButtonView( locale ), + new ButtonView( locale ), new ButtonView( locale ), new ButtonView( locale ) ], @@ -274,7 +277,7 @@ describe( 'ListPropertiesView', () => { } ); it( 'should popupate the view with style buttons', () => { - expect( view.stylesView.children.length ).to.equal( 2 ); + expect( view.stylesView.children.length ).to.equal( 5 ); expect( view.stylesView.children.get( 0 ) ).to.be.instanceOf( ButtonView ); expect( view.stylesView.children.get( 1 ) ).to.be.instanceOf( ButtonView ); expect( view.stylesView.element.firstChild.classList.contains( 'ck-button' ) ).to.be.true; @@ -308,8 +311,7 @@ describe( 'ListPropertiesView', () => { describe( 'when styles and all numbered list properties are enabled', () => { it( 'should register child views in #focusables', () => { expect( view.focusables.map( f => f ) ).to.have.members( [ - view.stylesView.children.first, - view.stylesView.children.last, + view.children.first, view.children.last.buttonView, view.startIndexFieldView, view.reversedSwitchButtonView @@ -330,15 +332,38 @@ describe( 'ListPropertiesView', () => { styleGridAriaLabel: 'Foo' } ); - const spy = sinon.spy( view.focusTracker, 'add' ); + const spyView = sinon.spy( view.focusTracker, 'add' ); view.render(); - sinon.assert.calledWithExactly( spy.getCall( 0 ), view.stylesView.children.first.element ); - sinon.assert.calledWithExactly( spy.getCall( 1 ), view.stylesView.children.last.element ); - sinon.assert.calledWithExactly( spy.getCall( 2 ), view.children.last.buttonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 3 ), view.startIndexFieldView.element ); - sinon.assert.calledWithExactly( spy.getCall( 4 ), view.reversedSwitchButtonView.element ); + sinon.assert.calledWithExactly( spyView.getCall( 0 ), view.children.first.element ); + sinon.assert.calledWithExactly( spyView.getCall( 1 ), view.children.last.buttonView.element ); + sinon.assert.calledWithExactly( spyView.getCall( 2 ), view.startIndexFieldView.element ); + sinon.assert.calledWithExactly( spyView.getCall( 3 ), view.reversedSwitchButtonView.element ); + + view.destroy(); + } ); + + it( 'should register style view\'s items in style view\'s focus tracker', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + styles: true, + startIndex: true, + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ), + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + const spyStylesView = sinon.spy( view.stylesView.focusTracker, 'add' ); + + view.render(); + + sinon.assert.calledWithExactly( spyStylesView.getCall( 0 ), view.stylesView.children.first.element ); + sinon.assert.calledWithExactly( spyStylesView.getCall( 1 ), view.stylesView.children.last.element ); view.destroy(); } ); @@ -423,11 +448,12 @@ describe( 'ListPropertiesView', () => { stopPropagation: sinon.spy() }; - // Mock the first style button is focused. + // Mock the styles view is focused. view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.stylesView.children.first.element; + view.focusTracker.focusedElement = view.children.first.element; - const spy = sinon.spy( view.stylesView.children.last, 'focus' ); + // Spy the next view which in this case is the ListProperties button + const spy = sinon.spy( view.children.last.buttonView, 'focus' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault ); @@ -443,11 +469,12 @@ describe( 'ListPropertiesView', () => { stopPropagation: sinon.spy() }; - // Mock the first style button is focused. + // Mock the styles view is focused. view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.stylesView.children.first.element; + view.focusTracker.focusedElement = view.children.first.element; view.children.last.isCollapsed = false; + // Spy the previous view which in this case is the Reversed order switch button const spy = sinon.spy( view.reversedSwitchButtonView, 'focus' ); view.keystrokes.press( keyEvtData ); @@ -455,6 +482,46 @@ describe( 'ListPropertiesView', () => { sinon.assert.calledOnce( keyEvtData.stopPropagation ); sinon.assert.calledOnce( spy ); } ); + + describe( 'keyboard navigation in the styles grid', () => { + it( '"arrow right" should focus the next focusable style button', () => { + const keyEvtData = { + keyCode: keyCodes.arrowright, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the first style button is focused. + view.stylesView.focusTracker.isFocused = true; + view.stylesView.focusTracker.focusedElement = view.stylesView.children.first.element; + + const spy = sinon.spy( view.stylesView.children.get( 1 ), 'focus' ); + + view.stylesView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( '"arrow down" should focus the focusable style button in the second row', () => { + const keyEvtData = { + keyCode: keyCodes.arrowdown, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the first style button is focused. + view.stylesView.focusTracker.isFocused = true; + view.stylesView.focusTracker.focusedElement = view.stylesView.children.first.element; + + const spy = sinon.spy( view.stylesView.children.get( 4 ), 'focus' ); + + view.stylesView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + } ); } ); it( 'intercepts the arrow* events and overrides the default (parent) toolbar behavior', () => { diff --git a/packages/ckeditor5-ui/src/bindings/addkeyboardhandlingforgrid.js b/packages/ckeditor5-ui/src/bindings/addkeyboardhandlingforgrid.js new file mode 100644 index 00000000000..9fc9c3c5df4 --- /dev/null +++ b/packages/ckeditor5-ui/src/bindings/addkeyboardhandlingforgrid.js @@ -0,0 +1,73 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/bindings/addkeyboardhandlingforgrid + */ + +/** + * A helper that adds a keyboard navigation support (arrow up/down/left/right) for grids. + * + * @param {Object} options Configuration options. + * @param {module:utils/keystrokehandler~KeystrokeHandler} options.keystrokeHandler Keystroke handler to register navigation with arrow + * keys. + * @param {module:utils/focustracker~FocusTracker} options.focusTracker A focus tracker for grid elements. + * @param {module:ui/viewcollection~ViewCollection} options.gridItems A collection of grid items. + * @param {Number} options.numberOfColumns Number of columns in the grid. + */ +export default function addKeyboardHandlingForGrid( { keystrokeHandler, focusTracker, gridItems, numberOfColumns } ) { + keystrokeHandler.set( 'arrowright', getGridItemFocuser( ( focusedElementIndex, gridItems ) => { + if ( focusedElementIndex === gridItems.length - 1 ) { + return 0; + } else { + return focusedElementIndex + 1; + } + } ) ); + + keystrokeHandler.set( 'arrowleft', getGridItemFocuser( ( focusedElementIndex, gridItems ) => { + if ( focusedElementIndex === 0 ) { + return gridItems.length - 1; + } else { + return focusedElementIndex - 1; + } + } ) ); + + keystrokeHandler.set( 'arrowup', getGridItemFocuser( ( focusedElementIndex, gridItems ) => { + let nextIndex = focusedElementIndex - numberOfColumns; + + if ( nextIndex < 0 ) { + nextIndex = focusedElementIndex + numberOfColumns * Math.floor( gridItems.length / numberOfColumns ); + + if ( nextIndex > gridItems.length - 1 ) { + nextIndex -= numberOfColumns; + } + } + + return nextIndex; + } ) ); + + keystrokeHandler.set( 'arrowdown', getGridItemFocuser( ( focusedElementIndex, gridItems ) => { + let nextIndex = focusedElementIndex + numberOfColumns; + + if ( nextIndex > gridItems.length - 1 ) { + nextIndex = focusedElementIndex % numberOfColumns; + } + + return nextIndex; + } ) ); + + function getGridItemFocuser( getIndexToFocus ) { + return evt => { + const focusedElement = gridItems.find( item => item.element === focusTracker.focusedElement ); + const focusedElementIndex = gridItems.getIndex( focusedElement ); + const nextIndexToFocus = getIndexToFocus( focusedElementIndex, gridItems ); + + gridItems.get( nextIndexToFocus ).focus(); + + evt.stopPropagation(); + evt.preventDefault(); + }; + } +} diff --git a/packages/ckeditor5-ui/src/colorgrid/colorgridview.js b/packages/ckeditor5-ui/src/colorgrid/colorgridview.js index 91406f559c0..7b2a7c5bf58 100644 --- a/packages/ckeditor5-ui/src/colorgrid/colorgridview.js +++ b/packages/ckeditor5-ui/src/colorgrid/colorgridview.js @@ -10,8 +10,9 @@ import View from '../view'; import ColorTileView from './colortileview'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; -import FocusCycler from '../focuscycler'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; +import addKeyboardHandlingForGrid from '../bindings/addkeyboardhandlingforgrid'; + import '../../theme/components/colorgrid/colorgrid.css'; /** @@ -27,7 +28,7 @@ export default class ColorGridView extends View { * @param {Object} options Component configuration * @param {Array.} [options.colorDefinitions] Array with definitions * required to create the {@link module:ui/colorgrid/colortile~ColorTileView tiles}. - * @param {Number} options.columns A number of columns to display the tiles. + * @param {Number} [options.columns=5] A number of columns to display the tiles. */ constructor( locale, options ) { super( locale ); @@ -35,9 +36,15 @@ export default class ColorGridView extends View { const colorDefinitions = options && options.colorDefinitions || []; const viewStyleAttribute = {}; - if ( options && options.columns ) { - viewStyleAttribute.gridTemplateColumns = `repeat( ${ options.columns }, 1fr)`; - } + /** + * A number of columns for the tiles grid. + * + * @readonly + * @member {Number} + */ + this.columns = options && options.columns ? options.columns : 5; + + viewStyleAttribute.gridTemplateColumns = `repeat( ${ this.columns }, 1fr)`; /** * The color of the currently selected color tile in {@link #items}. @@ -71,26 +78,6 @@ export default class ColorGridView extends View { */ this.keystrokes = new KeystrokeHandler(); - /** - * Helps cycling over focusable {@link #items} in the grid. - * - * @readonly - * @protected - * @member {module:ui/focuscycler~FocusCycler} - */ - this._focusCycler = new FocusCycler( { - focusables: this.items, - focusTracker: this.focusTracker, - keystrokeHandler: this.keystrokes, - actions: { - // Navigate grid items backwards using the arrowup key. - focusPrevious: 'arrowleft', - - // Navigate grid items forwards using the arrowdown key. - focusNext: 'arrowright' - } - } ); - this.items.on( 'add', ( evt, colorTile ) => { colorTile.isOn = colorTile.color === this.selectedColor; } ); @@ -174,6 +161,13 @@ export default class ColorGridView extends View { // Start listening for the keystrokes coming from #element. this.keystrokes.listenTo( this.element ); + + addKeyboardHandlingForGrid( { + keystrokeHandler: this.keystrokes, + focusTracker: this.focusTracker, + gridItems: this.items, + numberOfColumns: this.columns + } ); } /** diff --git a/packages/ckeditor5-ui/src/index.js b/packages/ckeditor5-ui/src/index.js index 1f391081e6a..560e17993c6 100644 --- a/packages/ckeditor5-ui/src/index.js +++ b/packages/ckeditor5-ui/src/index.js @@ -10,6 +10,7 @@ export { default as clickOutsideHandler } from './bindings/clickoutsidehandler'; export { default as injectCssTransitionDisabler } from './bindings/injectcsstransitiondisabler'; export { default as submitHandler } from './bindings/submithandler'; +export { default as addKeyboardHandlingForGrid } from './bindings/addkeyboardhandlingforgrid'; export { default as BodyCollection } from './editorui/bodycollection'; diff --git a/packages/ckeditor5-ui/tests/bindings/addkeyboardhandlingforgrid.js b/packages/ckeditor5-ui/tests/bindings/addkeyboardhandlingforgrid.js new file mode 100644 index 00000000000..8892cb57605 --- /dev/null +++ b/packages/ckeditor5-ui/tests/bindings/addkeyboardhandlingforgrid.js @@ -0,0 +1,247 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import addKeyboardHandlingForGrid from '../../src/bindings/addkeyboardhandlingforgrid'; +import View from '../../src/view'; +import ButtonView from '../../src/button/buttonview'; +import { KeystrokeHandler, FocusTracker, Locale, keyCodes } from '@ckeditor/ckeditor5-utils'; + +describe( 'addKeyboardHandlingForGrid()', () => { + let view, keystrokes, focusTracker, gridElementsCollection; + + beforeEach( () => { + view = new TestView(); + keystrokes = new KeystrokeHandler(); + focusTracker = new FocusTracker(); + + view.render(); + + gridElementsCollection = view.createCollection(); + + for ( let i = 0; i < 7; i++ ) { + const button = new ButtonView( new Locale() ); + + button.render(); + gridElementsCollection.add( button ); + focusTracker.add( button.element ); + } + + addKeyboardHandlingForGrid( { + keystrokeHandler: keystrokes, + focusTracker, + gridItems: gridElementsCollection, + numberOfColumns: 3 + } ); + + keystrokes.listenTo( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + keystrokes.destroy(); + focusTracker.destroy(); + } ); + + describe( 'basic arrow moves', () => { + it( 'arrowright moves focus to the next grid item', () => { + // before: [x][ ][ ] after: [ ][x][ ] key: → + // [ ][ ][ ] [ ][ ][ ] + // [ ] [ ] + focusTracker.focusedElement = gridElementsCollection.first.element; + + const spy = sinon.spy( gridElementsCollection.get( 1 ), 'focus' ); + + pressRightArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'arrowleft moves focus to the previous grid item', () => { + // before: [ ][x][ ] after: [x][ ][ ] key: ← + // [ ][ ][ ] [ ][ ][ ] + // [ ] [ ] + focusTracker.focusedElement = gridElementsCollection.get( 1 ).element; + + const spy = sinon.spy( gridElementsCollection.get( 0 ), 'focus' ); + + pressLeftArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'arrowdown moves focus 1 row below the grid item in the same column', () => { + // before: [x][ ][ ] after: [ ][ ][ ] key: ↓ + // [ ][ ][ ] [x][ ][ ] + // [ ] [ ] + focusTracker.focusedElement = gridElementsCollection.first.element; + + const spy = sinon.spy( gridElementsCollection.get( 3 ), 'focus' ); + + pressDownArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'arrowup moves focus 1 row above the grid item in the same column', () => { + // before: [ ][ ][ ] after: [x][ ][ ] key: ↑ + // [x][ ][ ] [ ][ ][ ] + // [ ] [ ] + focusTracker.focusedElement = gridElementsCollection.get( 3 ).element; + + const spy = sinon.spy( gridElementsCollection.first, 'focus' ); + + pressUpArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'arrow moves at the edges', () => { + it( 'arrowright at the last column moves focus to the beginning of the next row in the first column', () => { + // before: [ ][ ][x] after: [ ][ ][ ] key: → + // [ ][ ][ ] [x][ ][ ] + // [ ] [ ] + focusTracker.focusedElement = gridElementsCollection.get( 2 ).element; + + const spy = sinon.spy( gridElementsCollection.get( 3 ), 'focus' ); + + pressRightArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'arrowleft at the first column moves focus to the end of the previous row in the last column', () => { + // before: [ ][ ][ ] after: [ ][ ][x] key: ← + // [x][ ][ ] [ ][ ][ ] + // [ ] [ ] + focusTracker.focusedElement = gridElementsCollection.get( 3 ).element; + + const spy = sinon.spy( gridElementsCollection.get( 2 ), 'focus' ); + + pressLeftArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'arrowup at the first row moves focus to the last row in the same column', () => { + // before: [x][ ][ ] after: [ ][ ][ ] key: ↑ + // [ ][ ][ ] [ ][ ][ ] + // [ ] [x] + focusTracker.focusedElement = gridElementsCollection.first.element; + + const spy = sinon.spy( gridElementsCollection.get( 6 ), 'focus' ); + + pressUpArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'arrowdown at the last row moves focus to the first row in the same column', () => { + // before: [ ][ ][ ] after: [x][ ][ ] key: ↓ + // [ ][ ][ ] [ ][ ][ ] + // [x] [ ] + focusTracker.focusedElement = gridElementsCollection.get( 6 ).element; + + const spy = sinon.spy( gridElementsCollection.first, 'focus' ); + + pressDownArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'arrowup at the first row moves focus to the one before last row if here is no item in the last row for this column', () => { + // before: [ ][x][ ] after: [ ][ ][ ] key: ↑ + // [ ][ ][ ] [ ][x][ ] + // [ ] [ ] + focusTracker.focusedElement = gridElementsCollection.get( 1 ).element; + + const spy = sinon.spy( gridElementsCollection.get( 4 ), 'focus' ); + + pressUpArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'arrowdown at the one before last row moves focus to the first row if here is no item in the last row for this column', () => { + // before: [ ][ ][ ] after: [ ][x][ ] key: ↓ + // [ ][x][ ] [ ][ ][ ] + // [ ] [ ] + focusTracker.focusedElement = gridElementsCollection.get( 4 ).element; + + const spy = sinon.spy( gridElementsCollection.get( 1 ), 'focus' ); + + pressDownArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'first and last item', () => { + it( 'arrowleft moves focus to the last grid item if the first one was focused', () => { + // before: [x][ ][ ] after: [ ][ ][ ] key: ← + // [ ][ ][ ] [ ][ ][ ] + // [ ] [x] + focusTracker.focusedElement = gridElementsCollection.first.element; + + const spy = sinon.spy( gridElementsCollection.last, 'focus' ); + + pressLeftArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'arrowright moves focus to the first grid item if the last one was focused', () => { + // before: [ ][ ][ ] after: [x][ ][ ] key: → + // [ ][ ][ ] [ ][ ][ ] + // [x] [ ] + focusTracker.focusedElement = gridElementsCollection.last.element; + + const spy = sinon.spy( gridElementsCollection.first, 'focus' ); + + pressRightArrow( keystrokes ); + sinon.assert.calledOnce( spy ); + } ); + } ); + + class TestView extends View { + constructor( ...args ) { + super( ...args ); + + this.setTemplate( { + tag: 'div' + } ); + } + } +} ); + +function pressRightArrow( keystrokes ) { + const keyEvtData = { + keyCode: keyCodes.arrowright, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + keystrokes.press( keyEvtData ); +} + +function pressLeftArrow( keystrokes ) { + const keyEvtData = { + keyCode: keyCodes.arrowleft, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + keystrokes.press( keyEvtData ); +} + +function pressUpArrow( keystrokes ) { + const keyEvtData = { + keyCode: keyCodes.arrowup, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + keystrokes.press( keyEvtData ); +} + +function pressDownArrow( keystrokes ) { + const keyEvtData = { + keyCode: keyCodes.arrowdown, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + keystrokes.press( keyEvtData ); +} diff --git a/packages/ckeditor5-ui/tests/colorgrid/colorgridview.js b/packages/ckeditor5-ui/tests/colorgrid/colorgridview.js index bf2d754ce19..d95ee292a23 100644 --- a/packages/ckeditor5-ui/tests/colorgrid/colorgridview.js +++ b/packages/ckeditor5-ui/tests/colorgrid/colorgridview.js @@ -3,14 +3,16 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals Event */ +/* globals Event, document */ import ColorGridView from './../../src/colorgrid/colorgridview'; import ColorTileView from '../../src/colorgrid/colortileview'; + import ViewCollection from '../../src/viewcollection'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; -import FocusCycler from '../../src/focuscycler'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; + import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; describe( 'ColorGridView', () => { @@ -89,10 +91,6 @@ describe( 'ColorGridView', () => { expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); } ); - it( 'creates focus cycler', () => { - expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); - } ); - it( 'reacts to changes in #selectedColor by setting the item#isOn', () => { expect( view.items.map( item => item ).some( item => item.isOn ) ).to.be.false; @@ -141,6 +139,62 @@ describe( 'ColorGridView', () => { } ); } ); + describe( 'render()', () => { + describe( 'Focus management across the grid items using arrow keys', () => { + let view; + + beforeEach( () => { + view = new ColorGridView( locale, { colorDefinitions, columns: 2 } ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( '"arrow right" should focus the next focusable grid item', () => { + const keyEvtData = { + keyCode: keyCodes.arrowright, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the first grid item is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.items.first.element; + + const spy = sinon.spy( view.items.get( 1 ), 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( '"arrow down" should focus the focusable grid item in the second row', () => { + const keyEvtData = { + keyCode: keyCodes.arrowdown, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the first grid item is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.items.first.element; + + const spy = sinon.spy( view.items.get( 2 ), 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); + describe( 'destroy()', () => { it( 'should destroy the FocusTracker instance', () => { const destroySpy = sinon.spy( view.focusTracker, 'destroy' );