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' );