diff --git a/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts b/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts index 51cf1173fb1..55f1cd4faf2 100644 --- a/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts +++ b/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts @@ -22,9 +22,7 @@ import { ViewCollection, type FocusableView, type NormalizedColorOption, - type ColorPickerConfig, - type FocusCyclerBackwardCycleEvent, - type FocusCyclerForwardCycleEvent + type ColorPickerConfig } from 'ckeditor5/src/ui.js'; import { KeystrokeHandler, @@ -384,15 +382,7 @@ export default class TableCellPropertiesView extends View { // Maintain continuous focus cycling over views that have focusable children and focus cyclers themselves. [ this.borderColorInput, this.backgroundInput ].forEach( view => { - view.fieldView.focusCycler.on( 'forwardCycle', evt => { - this._focusCycler.focusNext(); - evt.stop(); - } ); - - view.fieldView.focusCycler.on( 'backwardCycle', evt => { - this._focusCycler.focusPrevious(); - evt.stop(); - } ); + this._focusCycler.chain( view.fieldView.focusCycler ); } ); [ diff --git a/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts b/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts index 210d08e040f..d80d68d8e57 100644 --- a/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts +++ b/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts @@ -24,8 +24,6 @@ import { type InputTextView, type NormalizedColorOption, type ColorPickerConfig, - type FocusCyclerForwardCycleEvent, - type FocusCyclerBackwardCycleEvent, type FocusableView } from 'ckeditor5/src/ui.js'; import { FocusTracker, KeystrokeHandler, type ObservableChangeEvent, type Locale } from 'ckeditor5/src/utils.js'; @@ -357,15 +355,7 @@ export default class TablePropertiesView extends View { // Maintain continuous focus cycling over views that have focusable children and focus cyclers themselves. [ this.borderColorInput, this.backgroundInput ].forEach( view => { - view.fieldView.focusCycler.on( 'forwardCycle', evt => { - this._focusCycler.focusNext(); - evt.stop(); - } ); - - view.fieldView.focusCycler.on( 'backwardCycle', evt => { - this._focusCycler.focusPrevious(); - evt.stop(); - } ); + this._focusCycler.chain( view.fieldView.focusCycler ); } ); [ diff --git a/packages/ckeditor5-ui/package.json b/packages/ckeditor5-ui/package.json index 37d5efa9710..666a0266824 100644 --- a/packages/ckeditor5-ui/package.json +++ b/packages/ckeditor5-ui/package.json @@ -53,6 +53,9 @@ "webpack": "^5.58.1", "webpack-cli": "^4.9.0" }, + "depcheckIgnore": [ + "sinon" + ], "author": "CKSource (http://cksource.com/)", "license": "GPL-2.0-or-later", "homepage": "https://ckeditor.com/ckeditor-5", diff --git a/packages/ckeditor5-ui/src/bindings/draggableviewmixin.ts b/packages/ckeditor5-ui/src/bindings/draggableviewmixin.ts index 847ebf7a356..d709a2a2abd 100644 --- a/packages/ckeditor5-ui/src/bindings/draggableviewmixin.ts +++ b/packages/ckeditor5-ui/src/bindings/draggableviewmixin.ts @@ -53,7 +53,7 @@ export default function DraggableViewMixin>( view private _lastDraggingCoordinates: { x: number; y: number } = { x: 0, y: 0 }; /** - * @inheritdoc + * @inheritDoc */ constructor( ...args: Array ) { super( ...args ); diff --git a/packages/ckeditor5-ui/src/dialog/dialogview.ts b/packages/ckeditor5-ui/src/dialog/dialogview.ts index a7742a00694..81090f41514 100644 --- a/packages/ckeditor5-ui/src/dialog/dialogview.ts +++ b/packages/ckeditor5-ui/src/dialog/dialogview.ts @@ -24,8 +24,6 @@ import FormHeaderView from '../formheader/formheaderview.js'; import ButtonView from '../button/buttonview.js'; import { type ButtonExecuteEvent } from '../button/button.js'; import FocusCycler, { isViewWithFocusCycler, - type FocusCyclerBackwardCycleEvent, - type FocusCyclerForwardCycleEvent, type FocusableView, isFocusable } @@ -652,23 +650,7 @@ export default class DialogView extends /* #__PURE__ */ DraggableViewMixin( View this.focusTracker.add( focusable.element! ); if ( isViewWithFocusCycler( focusable ) ) { - this.listenTo( focusable.focusCycler, 'forwardCycle', evt => { - this._focusCycler.focusNext(); - - // Stop the event propagation only if there are more focusables. - if ( this._focusCycler.next !== this._focusCycler.focusables.get( this._focusCycler.current! ) ) { - evt.stop(); - } - } ); - - this.listenTo( focusable.focusCycler, 'backwardCycle', evt => { - this._focusCycler.focusPrevious(); - - // Stop the event propagation only if there are more focusables. - if ( this._focusCycler.previous !== this._focusCycler.focusables.get( this._focusCycler.current! ) ) { - evt.stop(); - } - } ); + this._focusCycler.chain( focusable.focusCycler ); } } ); } diff --git a/packages/ckeditor5-ui/src/editorui/bodycollection.ts b/packages/ckeditor5-ui/src/editorui/bodycollection.ts index 6fe43fec328..e07074fb44d 100644 --- a/packages/ckeditor5-ui/src/editorui/bodycollection.ts +++ b/packages/ckeditor5-ui/src/editorui/bodycollection.ts @@ -76,7 +76,8 @@ export default class BodyCollection extends ViewCollection { 'ck-body', 'ck-rounded-corners' ], - dir: this.locale.uiLanguageDirection + dir: this.locale.uiLanguageDirection, + role: 'application' }, children: this } ).render() as HTMLElement; diff --git a/packages/ckeditor5-ui/src/focuscycler.ts b/packages/ckeditor5-ui/src/focuscycler.ts index 3116c1d3fa4..4d407dd590f 100644 --- a/packages/ckeditor5-ui/src/focuscycler.ts +++ b/packages/ckeditor5-ui/src/focuscycler.ts @@ -9,10 +9,11 @@ import { isVisible, + EmitterMixin, type ArrayOrItem, type FocusTracker, type KeystrokeHandler, - EmitterMixin + type KeystrokeHandlerOptions } from '@ckeditor/ckeditor5-utils'; import type View from './view.js'; @@ -115,6 +116,7 @@ export default class FocusCycler extends /* #__PURE__ */ EmitterMixin() { focusables: ViewCollection; focusTracker: FocusTracker; keystrokeHandler?: KeystrokeHandler; + keystrokeHandlerOptions?: KeystrokeHandlerOptions; actions?: FocusCyclerActions; } ) { super(); @@ -136,7 +138,7 @@ export default class FocusCycler extends /* #__PURE__ */ EmitterMixin() { options.keystrokeHandler.set( keystroke, ( data, cancel ) => { this[ methodName as keyof FocusCyclerActions ](); cancel(); - } ); + }, options.keystrokeHandlerOptions ); } } } @@ -274,6 +276,95 @@ export default class FocusCycler extends /* #__PURE__ */ EmitterMixin() { } } + /** + * Allows for creating continuous focus cycling across multiple focus cyclers and their collections of {@link #focusables}. + * + * It starts listening to the {@link module:ui/focuscycler~FocusCyclerForwardCycleEvent} and + * {@link module:ui/focuscycler~FocusCyclerBackwardCycleEvent} events of the chained focus cycler and engages, + * whenever the user reaches the last (forwards navigation) or first (backwards navigation) focusable view + * and would normally start over. Instead, the navigation continues on the higher level (flattens). + * + * For instance, for the following nested focus navigation structure, the focus would get stuck the moment + * the AB gets focused and its focus cycler starts managing it: + * + * ┌────────────┐ ┌──────────────────────────────────┐ ┌────────────┐ + * │ AA │ │ AB │ │ AC │ + * │ │ │ │ │ │ + * │ │ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │ + * │ │ │ ┌──► ABA ├──► ABB ├──► ABC ├───┐ │ │ │ + * │ ├───► │ └─────┘ └─────┘ └─────┘ │ │ │ │ + * │ │ │ │ │ │ │ │ + * │ │ │ │ │ │ │ │ + * │ │ │ └──────────────────────────────┘ │ │ │ + * │ │ │ │ │ │ + * └────────────┘ └──────────────────────────────────┘ └────────────┘ + * + * Chaining a focus tracker that manages AA, AB, and AC with the focus tracker that manages ABA, ABB, and ABC + * creates a seamless navigation experience instead: + * + * ┌────────────┐ ┌──────────────────────────────────┐ ┌────────────┐ + * │ AA │ │ AB │ │ AC │ + * │ │ │ │ │ │ + * │ │ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │ + * │ │ │ ┌──► ABA ├──► ABB ├──► ABC ├──┐ │ │ │ + * ┌──► ├───┼─┘ └─────┘ └─────┘ └─────┘ └──┼───► ├──┐ + * │ │ │ │ │ │ │ │ + * │ │ │ │ │ │ │ │ + * │ │ │ │ │ │ │ │ + * │ │ │ │ │ │ │ │ + * │ └────────────┘ └──────────────────────────────────┘ └────────────┘ │ + * │ │ + * │ │ + * └──────────────────────────────────────────────────────────────────────────┘ + * + * See {@link #unchain} to reverse the chaining. + */ + public chain( chainedFocusCycler: FocusCycler ): void { + const getCurrentFocusedView = () => { + // This may happen when one focus cycler does not include focusables of the other (horizontal case). + if ( this.current === null ) { + return null; + } + + return this.focusables.get( this.current ); + }; + + this.listenTo( chainedFocusCycler, 'forwardCycle', evt => { + const oldCurrent = getCurrentFocusedView(); + + this.focusNext(); + + // Stop the event propagation only if an attempt at focusing the view actually moved the focus. + // If not, let the otherFocusCycler handle the event. + if ( oldCurrent !== getCurrentFocusedView() ) { + evt.stop(); + } + + // The priority is critical for cycling across multiple chain levels when there's a single view at some of them only. + }, { priority: 'low' } ); + + this.listenTo( chainedFocusCycler, 'backwardCycle', evt => { + const oldCurrent = getCurrentFocusedView(); + + this.focusPrevious(); + + // Stop the event propagation only if an attempt at focusing the view actually moved the focus. + // If not, let the otherFocusCycler handle the event. + if ( oldCurrent !== getCurrentFocusedView() ) { + evt.stop(); + } + + // The priority is critical for cycling across multiple chain levels when there's a single view at some of them only. + }, { priority: 'low' } ); + } + + /** + * Reverses a chaining made by {@link #chain}. + */ + public unchain( otherFocusCycler: FocusCycler ): void { + this.stopListening( otherFocusCycler ); + } + /** * Focuses the given view if it exists. * @@ -362,12 +453,9 @@ export type ViewWithFocusCycler = FocusableView & { focusCycler: FocusCycler; }; -export interface FocusCyclerActions { - focusFirst?: ArrayOrItem; - focusLast?: ArrayOrItem; - focusNext?: ArrayOrItem; - focusPrevious?: ArrayOrItem; -} +export type FocusCyclerActions = { + [ key in 'focusFirst' | 'focusLast' | 'focusPrevious' | 'focusNext' ]?: ArrayOrItem +}; /** * Fired when the focus cycler is about to move the focus from the last focusable item diff --git a/packages/ckeditor5-ui/tests/_utils-tests/testfocuscycling.ts b/packages/ckeditor5-ui/tests/_utils-tests/testfocuscycling.ts new file mode 100644 index 00000000000..b7a7b03291a --- /dev/null +++ b/packages/ckeditor5-ui/tests/_utils-tests/testfocuscycling.ts @@ -0,0 +1,252 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document, KeyboardEvent */ + +import { isVisible, parseKeystroke, wait } from '@ckeditor/ckeditor5-utils'; +import { View, type ViewCollection } from '../../src/index.js'; +import type { FocusableView, FocusCyclerActions, ViewWithFocusCycler } from '../../src/focuscycler.js'; +import sinon from 'sinon'; + +/** + * Automates testing of focus cycling in a view with a focus cycler. It runs a test per each configured action. + * + * When `addFocusables()` or `removeFocusables()` are provided, additional tests are run to check how the focus cycling + * works when the collection of focusable views is modified. + */ +export default function testFocusCycling( { + getView, + getFocusablesCollection, + actions, + addFocusables, + removeFocusables, + expectedFocusedElements, + triggerAction = defaultDispatchDomKeyboardEvent +}: TestFocusCyclingOptions ): void { + let action: keyof typeof actions; + + for ( action in actions ) { + const keystroke = actions[ action ] as string; + const keystrokeCode = parseKeystroke( keystroke ); + + test( { + action, + keystroke, + keystrokeCode, + expectedFocusedElements + } ); + + if ( addFocusables ) { + test( { + action, + keystroke, + keystrokeCode, + modifyFocusables: addFocusables, + description: ' after adding new items' + } ); + } + + if ( removeFocusables ) { + test( { + action, + keystroke, + keystrokeCode, + modifyFocusables: removeFocusables, + description: ' after removing some items' + } ); + } + } + + function test( { + action, + keystroke, + keystrokeCode, + modifyFocusables, + expectedFocusedElements, + description = '' + }: { + action: keyof FocusCyclerActions; + keystroke: string; + keystrokeCode: number; + modifyFocusables?: () => void; + expectedFocusedElements?: TestFocusCyclingOptions[ 'expectedFocusedElements' ]; + description?: string; + } ) { + it( `should execute the "${ action }" action upon pressing "${ keystroke }"${ description }`, async () => { + if ( !getView().element ) { + throw new Error( 'testFocusCycling() helper: Render the view before testing.' ); + } + + if ( !document.body.contains( getView().element ) ) { + throw new Error( 'testFocusCycling() helper: The view element is not attached to the DOM.' ); + } + + const focusables = getFocusablesCollection(); + + if ( !focusables.length ) { + throw new Error( + 'testFocusCycling() helper: The collection of focusable views is empty.' + ); + } + + if ( modifyFocusables ) { + const focusablesCountBeforeModify = focusables.length; + + await modifyFocusables(); + + if ( focusablesCountBeforeModify === focusables.length ) { + throw new Error( + 'testFocusCycling() helper: The number of focusables is the same after calling ' + + 'addFocusables() or removeFocusables().' + ); + } + } + + const visibleFocusables = Array.from( focusables ).filter( view => isVisible( view.element ) ); + const focusSpies = visibleFocusables.map( view => sinon.spy( view, 'focus' ) ); + + getView().focusCycler.focusFirst(); + + await wait( 10 ); + + let currentView = focusables.get( getView().focusCycler.current! )!; + let currentElement = document.activeElement as HTMLElement; + const visitedElements: Array = []; + + while ( !visitedElements.includes( currentElement ) ) { + visitedElements.push( currentElement ); + + const event = triggerAction( { + action, + keystroke, + keystrokeCode, + currentElement, + currentView + } ); + + await wait( 10 ); + + if ( event ) { + sinon.assert.calledOnce( event.preventDefault as sinon.SinonSpy ); + sinon.assert.calledOnce( event.stopPropagation as sinon.SinonSpy ); + } + + currentElement = document.activeElement as HTMLElement; + currentView = visibleFocusables.find( view => view.element!.contains( currentElement ) )!; + } + + if ( expectedFocusedElements ) { + const expectedElements = expectedFocusedElements[ action ]!( getView() ); + + expect( visitedElements, 'Elements visited by focus' ).to.have.ordered.members( expectedElements ); + } + + expect( focusSpies.map( spy => spy.called ).every( isCalled => isCalled ), 'Focus was called' ).to.be.true; + + if ( action === 'focusNext' ) { + sinon.assert.callOrder( ...focusSpies ); + } else { + sinon.assert.callOrder( ...focusSpies.reverse() ); + } + } ); + } +} + +export function getDomKeyboardEvent( keyCode: number, options = { bubbles: true } ): KeyboardEvent { + const event = new KeyboardEvent( 'keydown', { + keyCode, + ...options + } ); + + sinon.spy( event, 'preventDefault' ); + sinon.spy( event, 'stopPropagation' ); + + return event; +} + +export class FocusableTestView extends View { + constructor( text = 'test' ) { + super(); + + this.setTemplate( { + tag: 'div', + attributes: { + tabindex: -1 + }, + children: [ + { + text + } + ] + } ); + } + + public focus(): void { + this.element!.focus(); + } +} + +function defaultDispatchDomKeyboardEvent( { + keystrokeCode, + currentElement +} ) { + const event = getDomKeyboardEvent( keystrokeCode ); + + currentElement.dispatchEvent( event ); + + return event; +} + +type TestFocusCyclingOptions = { + + /** + * The view with the focus cycler that gets tested. + */ + getView: () => ViewWithFocusCycler; + + /** + * The collection of focusable views that the focus cycler should cycle through. + */ + getFocusablesCollection: () => ViewCollection; + + /** + * The focus cycler actions that should be tested. + */ + actions: FocusCyclerActions; + + /** + * When specified, this callback should extends the collection of focusable views. + * This will result with additional test being run that checks what happens when new focusables are added. + */ + addFocusables?: () => void; + + /** + * When specified, this callback should remove from the collection of focusable views. + * This will result with additional test being run that checks what happens when new focusables are removed. + */ + removeFocusables?: () => void; + + /** + * An optional expected array of DOM elements (ordered) visited by the focus for each action. + * + * Note: Assertion works only for simple test (without `addFocusables()` or `removeFocusables()`). + */ + expectedFocusedElements?: { + [ key in keyof FocusCyclerActions]: ( view: FocusableView ) => Array + }; + + /** + * An optional callback that should move the focus upon a specific action. This is useful for focus cycler + * configurations that do not come with a configured actions set but are nudged manually, for instance by + * calling `FocusCycler#focusNext()` or `focusPrevious()`. + */ + triggerAction?: ( data: { + action: keyof FocusCyclerActions; + currentView: View; + keystroke: string; + keystrokeCode: number; + currentElement: HTMLElement; + } ) => KeyboardEvent | undefined; +}; diff --git a/packages/ckeditor5-ui/tests/editorui/bodycollection.js b/packages/ckeditor5-ui/tests/editorui/bodycollection.js index 9f86492c63e..cc4b21f3092 100644 --- a/packages/ckeditor5-ui/tests/editorui/bodycollection.js +++ b/packages/ckeditor5-ui/tests/editorui/bodycollection.js @@ -65,6 +65,16 @@ describe( 'BodyCollection', () => { expect( el.classList.contains( 'ck-reset_all' ) ).to.be.true; } ); + it( 'sets the role attirbute', () => { + const body = new BodyCollection( locale ); + + body.attachToDom(); + + const el = body.bodyCollectionContainer; + + expect( el.getAttribute( 'role' ) ).to.equal( 'application' ); + } ); + it( 'sets the right dir attribute to the body region (LTR)', () => { const body = new BodyCollection( locale ); diff --git a/packages/ckeditor5-ui/tests/focuscycler.js b/packages/ckeditor5-ui/tests/focuscycler.js index dc4654f55a4..d28e6ee959c 100644 --- a/packages/ckeditor5-ui/tests/focuscycler.js +++ b/packages/ckeditor5-ui/tests/focuscycler.js @@ -11,6 +11,7 @@ import FocusCycler, { isViewWithFocusCycler } from '../src/focuscycler.js'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler.js'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; +import { FocusTracker, wait } from '@ckeditor/ckeditor5-utils'; describe( 'FocusCycler', () => { let focusables, focusTracker, cycler, viewIndex; @@ -518,6 +519,454 @@ describe( 'FocusCycler', () => { } ); } ); + describe( 'chain()', () => { + let rootFocusablesCollection, rootFocusTracker, rootCycler; + let viewBFocusablesCollection, viewBFocusTracker, viewBCycler; + + beforeEach( () => { + ( { + focusCycler: rootCycler, + focusTracker: rootFocusTracker, + focusables: rootFocusablesCollection + } = getCycleTestTools() ); + + ( { + focusCycler: viewBCycler, + focusTracker: viewBFocusTracker, + focusables: viewBFocusablesCollection + } = getCycleTestTools() ); + } ); + + it( 'should allow for continuous cycling across two focus cyclers ("forwardCycle" event handling)', async () => { + // This test creates the following structure and starts cycling forward over children of + // to see whether the focus will exit and move to . + // + // + // + // <-- start here and go forward + // + // + // + + const viewBChildren = [ + focusable( { dataset: { + id: 'BA' + } } ), + focusable( { dataset: { + id: 'BB' + } } ) + ]; + + const viewB = focusable( { + children: [ ...viewBChildren ], + dataset: { + id: 'B' + } + } ); + + rootFocusablesCollection.addMany( [ + focusable( { dataset: { + id: 'A' + } } ), + viewB, + focusable( { dataset: { + id: 'C' + } } ) + ] ); + + viewBFocusablesCollection.addMany( viewBChildren ); + rootCycler.chain( viewBCycler ); + + // --------------------------------------------------------------------------- + + expect( rootFocusTracker.focusedElement ).to.equal( null ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + + viewBCycler.focusFirst(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( viewB.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( viewBFocusablesCollection.first.element ); + + // --------------------------------------------------------------------------- + + viewBCycler.focusNext(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( viewB.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( viewBFocusablesCollection.get( 1 ).element ); + + // --------------------------------------------------------------------------- + + // This should exit the chained view and continue in the parent view. + viewBCycler.focusNext(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( rootFocusablesCollection.get( 2 ).element ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + + // --------------------------------------------------------------------------- + + rootCycler.focusNext(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( rootFocusablesCollection.get( 0 ).element ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + } ); + + it( 'should allow for continuous cycling across two focus cyclers ("backwardCycle" event handling)', async () => { + // This test creates the following structure and starts cycling backward over children of + // to see whether the focus will exit and move to . + // + // + // + // + // <-- start here and go backward + // + // + + const viewBChildren = [ + focusable( { dataset: { + id: 'BA' + } } ), + focusable( { dataset: { + id: 'BB' + } } ) + ]; + + const viewB = focusable( { + children: [ ...viewBChildren ], + dataset: { + id: 'B' + } + } ); + + rootFocusablesCollection.addMany( [ + focusable( { dataset: { + id: 'A' + } } ), + viewB, + focusable( { dataset: { + id: 'C' + } } ) + ] ); + + viewBFocusablesCollection.addMany( viewBChildren ); + rootCycler.chain( viewBCycler ); + + // --------------------------------------------------------------------------- + + expect( rootFocusTracker.focusedElement ).to.equal( null ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + + viewBCycler.focusLast(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( viewB.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( viewBFocusablesCollection.last.element ); + + // --------------------------------------------------------------------------- + + viewBCycler.focusPrevious(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( viewB.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( viewBFocusablesCollection.first.element ); + + // --------------------------------------------------------------------------- + + // This should exit the chained view and continue in the parent view. + viewBCycler.focusPrevious(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( rootFocusablesCollection.first.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + + // --------------------------------------------------------------------------- + rootCycler.focusPrevious(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( rootFocusablesCollection.last.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + } ); + + it( 'should allow for cycling in deep chains with single focusable view at some levels (forward)', async () => { + // + // + // + // + // + // + // + + const { + focusCycler: viewBACycler, + focusTracker: viewBAFocusTracker, + focusables: viewBAFocusablesCollection + } = getCycleTestTools(); + + const viewBAChildren = [ + focusable( { dataset: { + id: 'BAA' + } } ), + focusable( { dataset: { + id: 'BAB' + } } ) + ]; + + const viewBA = focusable( { + children: [ ...viewBAChildren ], + dataset: { + id: 'BA' + } + } ); + + viewBAFocusablesCollection.addMany( viewBAChildren ); + + const viewBChildren = [ + viewBA + ]; + + const viewB = focusable( { + children: [ ...viewBChildren ], + dataset: { + id: 'B' + } + } ); + + viewBFocusablesCollection.addMany( viewBChildren ); + + rootFocusablesCollection.addMany( [ + focusable( { dataset: { + id: 'A' + } } ), + viewB + ] ); + + rootCycler.chain( viewBCycler ); + viewBCycler.chain( viewBACycler ); + + // --------------------------------------------------------------------------- + + expect( rootFocusTracker.focusedElement ).to.equal( null ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + expect( viewBAFocusTracker.focusedElement ).to.equal( null ); + + // + // + // + // <-- focus goes here + // + // + // + viewBACycler.focusFirst(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( viewB.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( viewBA.element ); + expect( viewBAFocusTracker.focusedElement ).to.equal( viewBAFocusablesCollection.first.element ); + + // + // + // + // + // <-- focus goes here + // + // + viewBACycler.focusNext(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( viewB.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( viewBA.element ); + expect( viewBAFocusTracker.focusedElement ).to.equal( viewBAFocusablesCollection.last.element ); + + // This should exit the chained view and continue to because there's no other view at the level to focus. + // + // <-- focus goes here + // + // + // + // + // + // + viewBACycler.focusNext(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( rootFocusablesCollection.first.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + expect( viewBAFocusTracker.focusedElement ).to.equal( null ); + } ); + + it( 'should allow for cycling in deep chains with single focusable view at some levels (backward)', async () => { + // + // + // + // + // + // + // + + const { + focusCycler: viewBACycler, + focusTracker: viewBAFocusTracker, + focusables: viewBAFocusablesCollection + } = getCycleTestTools(); + + const viewBAChildren = [ + focusable( { dataset: { + id: 'BAA' + } } ), + focusable( { dataset: { + id: 'BAB' + } } ) + ]; + + const viewBA = focusable( { + children: [ ...viewBAChildren ], + dataset: { + id: 'BA' + } + } ); + + viewBAFocusablesCollection.addMany( viewBAChildren ); + + const viewBChildren = [ + viewBA + ]; + + const viewB = focusable( { + children: [ ...viewBChildren ], + dataset: { + id: 'B' + } + } ); + + viewBFocusablesCollection.addMany( viewBChildren ); + + rootFocusablesCollection.addMany( [ + focusable( { dataset: { + id: 'A' + } } ), + viewB + ] ); + + rootCycler.chain( viewBCycler ); + viewBCycler.chain( viewBACycler ); + + // --------------------------------------------------------------------------- + + expect( rootFocusTracker.focusedElement ).to.equal( null ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + expect( viewBAFocusTracker.focusedElement ).to.equal( null ); + + // + // + // + // + // <-- focus goes here + // + // + viewBACycler.focusLast(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( viewB.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( viewBA.element ); + expect( viewBAFocusTracker.focusedElement ).to.equal( viewBAFocusablesCollection.last.element ); + + // + // + // + // <-- focus goes here + // + // + // + viewBACycler.focusPrevious(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( viewB.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( viewBA.element ); + expect( viewBAFocusTracker.focusedElement ).to.equal( viewBAFocusablesCollection.first.element ); + + // This should exit the chained view and continue to because there's no other view at the level to focus. + // + // <-- focus goes here + // + // + // + // + // + // + viewBACycler.focusPrevious(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( rootFocusablesCollection.first.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + expect( viewBAFocusTracker.focusedElement ).to.equal( null ); + } ); + + it( 'should work for focus cycler of views that do not contain one another (horizontal navigation)', async () => { + // This test creates the following structure and starts cycling forward over children of + // to see whether the focus will exit and move to . + // + // + // + // + // <-- start here and go forward + // + + rootFocusablesCollection.addMany( [ + focusable( { dataset: { + id: 'AA' + } } ), + focusable( { dataset: { + id: 'AB' + } } ) + ] ); + + viewBFocusablesCollection.addMany( [ + focusable( { dataset: { + id: 'BA' + } } ), + focusable( { dataset: { + id: 'BB' + } } ) + ] ); + + rootCycler.chain( viewBCycler ); + + // --------------------------------------------------------------------------- + + expect( rootFocusTracker.focusedElement ).to.equal( null ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + + viewBCycler.focusFirst(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( null ); + expect( viewBFocusTracker.focusedElement ).to.equal( viewBFocusablesCollection.first.element ); + + // --------------------------------------------------------------------------- + + viewBCycler.focusNext(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( null ); + expect( viewBFocusTracker.focusedElement ).to.equal( viewBFocusablesCollection.get( 1 ).element ); + + // --------------------------------------------------------------------------- + + // This should exit the chained view and continue in the parent view. + viewBCycler.focusNext(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( rootFocusablesCollection.first.element ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + + // --------------------------------------------------------------------------- + + rootCycler.focusNext(); + await wait( 10 ); + expect( rootFocusTracker.focusedElement ).to.equal( rootFocusablesCollection.get( 1 ).element ); + expect( viewBFocusTracker.focusedElement ).to.equal( null ); + } ); + } ); + + describe( 'unchain()', () => { + it( 'should stop listening to another focus cycler', () => { + const { focusCycler: focusCyclerA } = getCycleTestTools(); + const { focusCycler: focusCyclerB } = getCycleTestTools(); + + const spy = sinon.spy( focusCyclerA, 'stopListening' ); + + focusCyclerA.unchain( focusCyclerB ); + + sinon.assert.calledWithExactly( spy, focusCyclerB ); + } ); + } ); + describe( 'keystrokes', () => { it( 'creates event listeners', () => { const keystrokeHandler = new KeystrokeHandler(); @@ -580,6 +1029,36 @@ describe( 'FocusCycler', () => { sinon.assert.calledOnce( keyEvtData.preventDefault ); sinon.assert.calledOnce( keyEvtData.stopPropagation ); } ); + + it( 'should support keystroke handler filtering', () => { + const keystrokeHandler = new KeystrokeHandler(); + + cycler = new FocusCycler( { + focusables, focusTracker, keystrokeHandler, + actions: { + focusPrevious: [ 'arrowup', 'arrowleft' ] + }, + keystrokeHandlerOptions: { + filter: evt => evt.foo + } + } ); + + const keyEvtData = { + keyCode: keyCodes.arrowleft, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + const spy = sinon.spy( cycler, 'focusPrevious' ); + + keystrokeHandler.press( keyEvtData ); + + sinon.assert.notCalled( spy ); + + keyEvtData.foo = true; + keystrokeHandler.press( keyEvtData ); + sinon.assert.calledOnce( spy ); + } ); } ); describe( 'isViewWithFocusCycler', () => { @@ -592,12 +1071,20 @@ describe( 'FocusCycler', () => { } ); } ); - function nonFocusable( { display = 'block', isDetached = false, hiddenParent = false } = {} ) { + function nonFocusable( { display = 'block', isDetached = false, hiddenParent = false, children = [], dataset = {} } = {} ) { const view = new View(); view.element = document.createElement( 'div' ); view.element.setAttribute( 'focus-cycler-test-element', viewIndex++ ); view.element.style.display = display; + for ( const child of children ) { + view.element.appendChild( child.element ); + } + + for ( const key in dataset ) { + view.element.dataset[ key ] = dataset[ key ]; + } + if ( hiddenParent ) { const invisibleParent = document.createElement( 'div' ); invisibleParent.style.display = 'none'; @@ -613,7 +1100,13 @@ describe( 'FocusCycler', () => { function focusable( ...args ) { const view = nonFocusable( ...args ); - view.focus = sinon.spy(); + view.focus = () => { + view.element.focus(); + }; + + view.element.setAttribute( 'tabindex', -1 ); + + sinon.spy( view, 'focus' ); return view; } @@ -634,5 +1127,27 @@ describe( 'FocusCycler', () => { return view; } + + function getCycleTestTools() { + const focusables = new ViewCollection(); + const focusTracker = new FocusTracker(); + + focusables.on( 'change', ( evt, { added, removed } ) => { + for ( const view of added ) { + focusTracker.add( view.element ); + } + + for ( const view of removed ) { + focusTracker.remove( view.element ); + } + } ); + + const focusCycler = new FocusCycler( { + focusables, + focusTracker + } ); + + return { focusCycler, focusTracker, focusables }; + } } ); diff --git a/packages/ckeditor5-utils/src/index.ts b/packages/ckeditor5-utils/src/index.ts index 2e49dc415ff..008fd28c41d 100644 --- a/packages/ckeditor5-utils/src/index.ts +++ b/packages/ckeditor5-utils/src/index.ts @@ -81,7 +81,7 @@ export { } from './collection.js'; export { default as first } from './first.js'; export { default as FocusTracker } from './focustracker.js'; -export { default as KeystrokeHandler } from './keystrokehandler.js'; +export { default as KeystrokeHandler, type KeystrokeHandlerOptions } from './keystrokehandler.js'; export { default as toArray, type ArrayOrItem, type ReadonlyArrayOrItem } from './toarray.js'; export { default as toMap } from './tomap.js'; export { default as priorities, type PriorityString } from './priorities.js'; diff --git a/packages/ckeditor5-utils/src/keystrokehandler.ts b/packages/ckeditor5-utils/src/keystrokehandler.ts index b40402e3ed9..67d83ed5862 100644 --- a/packages/ckeditor5-utils/src/keystrokehandler.ts +++ b/packages/ckeditor5-utils/src/keystrokehandler.ts @@ -87,14 +87,11 @@ export default class KeystrokeHandler { * {@link module:engine/view/observer/keyobserver~KeyEventData key event data} object and * a helper function to call both `preventDefault()` and `stopPropagation()` on the underlying event. * @param options Additional options. - * @param options.priority The priority of the keystroke - * callback. The higher the priority value the sooner the callback will be executed. Keystrokes having the same priority - * are called in the order they were added. */ public set( keystroke: string | ReadonlyArray, callback: ( ev: KeyboardEvent, cancel: () => void ) => void, - options: { readonly priority?: PriorityString } = {} + options: KeystrokeHandlerOptions = {} ): void { const keyCode = parseKeystroke( keystroke ); const priority = options.priority; @@ -102,6 +99,10 @@ export default class KeystrokeHandler { // Execute the passed callback on KeystrokeHandler#_keydown. // TODO: https://github.com/ckeditor/ckeditor5-utils/issues/144 this._listener.listenTo( this._listener, '_keydown:' + keyCode, ( evt, keyEvtData: KeyboardEvent ) => { + if ( options.filter && !options.filter( keyEvtData ) ) { + return; + } + callback( keyEvtData, () => { // Stop the event in the DOM: no listener in the web page // will be triggered by this event. @@ -142,3 +143,21 @@ export default class KeystrokeHandler { this.stopListening(); } } + +/** + * {@link module:utils/keystrokehandler~KeystrokeHandler#set} method options. + */ +export interface KeystrokeHandlerOptions { + + /** + * The priority of the keystroke callback. The higher the priority value the sooner the callback will be executed. + * Keystrokes having the same priority are called in the order they were added. + */ + readonly priority?: PriorityString; + + /** + * An optional callback function allowing for filtering keystrokes based on arbitrary criteria. + * The callback function receives `keydown` DOM event as a parameter. + */ + readonly filter?: ( keyEvtData: KeyboardEvent ) => boolean; +} diff --git a/packages/ckeditor5-utils/tests/keystrokehandler.js b/packages/ckeditor5-utils/tests/keystrokehandler.js index 1c1b7b9a034..93b2547a53d 100644 --- a/packages/ckeditor5-utils/tests/keystrokehandler.js +++ b/packages/ckeditor5-utils/tests/keystrokehandler.js @@ -140,6 +140,24 @@ describe( 'KeystrokeHandler', () => { sinon.assert.callOrder( spy2, spy1, spy4 ); sinon.assert.notCalled( spy3 ); } ); + + it( 'should support event filtering using a callback', () => { + const spy = sinon.spy(); + + const keyEvtDataFails = getCtrlA(); + const keyEvtDataPasses = getCtrlA(); + keyEvtDataPasses.foo = true; + + keystrokes.set( 'Ctrl+A', spy, { + filter: evt => evt.foo + } ); + + emitter.fire( 'keydown', keyEvtDataFails ); + sinon.assert.notCalled( spy ); + + emitter.fire( 'keydown', keyEvtDataPasses ); + sinon.assert.calledOnce( spy ); + } ); } ); describe( 'stopListening()', () => {