diff --git a/packages/ckeditor5-ui/src/focuscycler.ts b/packages/ckeditor5-ui/src/focuscycler.ts index b41be6b35c9..e8d8f6157fb 100644 --- a/packages/ckeditor5-ui/src/focuscycler.ts +++ b/packages/ckeditor5-ui/src/focuscycler.ts @@ -11,7 +11,8 @@ import { isVisible, type ArrayOrItem, type FocusTracker, - type KeystrokeHandler + type KeystrokeHandler, + EmitterMixin } from '@ckeditor/ckeditor5-utils'; import type View from './view'; @@ -69,7 +70,7 @@ import type ViewCollection from './viewcollection'; * * Check out the {@glink framework/deep-dive/ui/focus-tracking "Deep dive into focus tracking"} guide to learn more. */ -export default class FocusCycler { +export default class FocusCycler extends EmitterMixin() { /** * A {@link module:ui/view~View view} collection that the cycler operates on. */ @@ -116,6 +117,8 @@ export default class FocusCycler { keystrokeHandler?: KeystrokeHandler; actions?: FocusCyclerActions; } ) { + super(); + this.focusables = options.focusables; this.focusTracker = options.focusTracker; this.keystrokeHandler = options.keystrokeHandler; @@ -146,7 +149,7 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public get first(): FocusableView | null { - return ( this.focusables.find( isFocusable ) || null ) as FocusableView | null; + return ( this.focusables.find( isPlainFocusableView ) || null ) as FocusableView | null; } /** @@ -156,7 +159,7 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public get last(): FocusableView | null { - return ( this.focusables.filter( isFocusable ).slice( -1 )[ 0 ] || null ) as FocusableView | null; + return ( this.focusables.filter( isPlainFocusableView ).slice( -1 )[ 0 ] || null ) as FocusableView | null; } /** @@ -210,7 +213,7 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusFirst(): void { - this._focus( this.first ); + this._focus( this.first, 1 ); } /** @@ -219,7 +222,7 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusLast(): void { - this._focus( this.last ); + this._focus( this.last, -1 ); } /** @@ -228,7 +231,13 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusNext(): void { - this._focus( this.next ); + const next = this.next; + + this._focus( next, 1 ); + + if ( next == this.first ) { + this.fire( 'forwardCycle' ); + } } /** @@ -237,14 +246,34 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusPrevious(): void { - this._focus( this.previous ); + const previous = this.previous; + + this._focus( previous, -1 ); + + if ( previous == this.last ) { + this.fire( 'backwardCycle' ); + } } /** * Focuses the given view if it exists. + * + * @param view The view to be focused + * @param childrenFocusDirection The direction of the focus if the view has focusable children. + * @returns */ - private _focus( view: FocusableView | null ) { - if ( view ) { + private _focus( view: FocusableView | null, childrenFocusDirection: 1 | -1 ) { + if ( !view ) { + return; + } + + if ( isViewWithFocusableChildren( view ) ) { + if ( childrenFocusDirection === 1 ) { + view.focusFirst(); + } else if ( childrenFocusDirection === -1 ) { + view.focusLast(); + } + } else { view.focus(); } } @@ -276,7 +305,7 @@ export default class FocusCycler { do { const view = this.focusables.get( index )!; - if ( isFocusable( view ) ) { + if ( isPlainFocusableView( view ) ) { return view as FocusableView; } @@ -288,12 +317,16 @@ export default class FocusCycler { } } -/** - * A {@link module:ui/view~View} that can be focused, meaning it has a `focus()` method. - */ -export interface FocusableView extends View { +export type FocusableView = SimpleFocusableView | ViewWithFocusableChildren; + +export type SimpleFocusableView = View & { focus(): void; -} +}; + +export type ViewWithFocusableChildren = SimpleFocusableView & { + focusFirst(): void; + focusLast(): void; +}; export interface FocusCyclerActions { focusFirst?: ArrayOrItem; @@ -302,11 +335,40 @@ export interface FocusCyclerActions { focusPrevious?: ArrayOrItem; } +/** + * Fired when the focus cycler is about to move the focus from the last focusable item + * to the first one. + * + * @eventName ~FocusCycler#forwardCycle + */ +export type FocusCyclerForwardCycleEvent = { + name: 'forwardCycle'; + args: []; +}; + +/** + * Fired when the focus cycler is about to move the focus from the first focusable item + * to the last one. + * + * @eventName ~FocusCycler#backwardCycle + */ +export type FocusCyclerBackwardCycleEvent = { + name: 'backwardCycle'; + args: []; +}; + /** * Checks whether a view is focusable. * * @param view A view to be checked. */ -function isFocusable( view: View & { focus?: unknown } ) { - return !!( view.focus && isVisible( view.element ) ); +function isPlainFocusableView( view: View ): view is SimpleFocusableView { + return !!( 'focus' in view && isVisible( view.element ) ); +} + +/** + * Checks whether the view meets the requirements of the {@link ViewWithFocusableChildren} interface. + */ +function isViewWithFocusableChildren( view: View ): view is ViewWithFocusableChildren { + return isPlainFocusableView( view ) && 'focusFirst' in view && 'focusLast' in view; } diff --git a/packages/ckeditor5-ui/src/index.ts b/packages/ckeditor5-ui/src/index.ts index 0e217e089cb..36c7fa917d9 100644 --- a/packages/ckeditor5-ui/src/index.ts +++ b/packages/ckeditor5-ui/src/index.ts @@ -49,7 +49,14 @@ export { default as BoxedEditorUIView } from './editorui/boxed/boxededitoruiview export { default as InlineEditableUIView } from './editableui/inline/inlineeditableuiview'; export { default as FormHeaderView } from './formheader/formheaderview'; -export { default as FocusCycler, type FocusableView } from './focuscycler'; +export { + default as FocusCycler, + type FocusableView, + type SimpleFocusableView, + type ViewWithFocusableChildren, + type FocusCyclerForwardCycleEvent, + type FocusCyclerBackwardCycleEvent +} from './focuscycler'; export { default as IconView } from './icon/iconview'; export { default as InputView } from './input/inputview'; diff --git a/packages/ckeditor5-ui/src/list/listview.ts b/packages/ckeditor5-ui/src/list/listview.ts index 5a77687ba48..14fdc21810a 100644 --- a/packages/ckeditor5-ui/src/list/listview.ts +++ b/packages/ckeditor5-ui/src/list/listview.ts @@ -185,6 +185,13 @@ export default class ListView extends View implements Dropdown this._focusCycler.focusFirst(); } + /** + * Focuses the first focusable in {@link #items}. + */ + public focusFirst(): void { + this._focusCycler.focusFirst(); + } + /** * Focuses the last focusable in {@link #items}. */ diff --git a/packages/ckeditor5-ui/src/search/filteredview.ts b/packages/ckeditor5-ui/src/search/filteredview.ts index 4dcc657a95c..e64687f5ff1 100644 --- a/packages/ckeditor5-ui/src/search/filteredview.ts +++ b/packages/ckeditor5-ui/src/search/filteredview.ts @@ -3,8 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import type { FocusableView } from '../focuscycler'; -import type View from '../view'; +import type { ViewWithFocusableChildren } from '../focuscycler'; /** * @module ui/search/filteredview @@ -13,7 +12,7 @@ import type View from '../view'; /** * A view that can be filtered by a {@link module:ui/search/text/searchtextview~SearchTextView}. */ -export default interface FilteredView extends View, FocusableView { +export default interface FilteredView extends ViewWithFocusableChildren { /** * Filters the view by the given regular expression. diff --git a/packages/ckeditor5-ui/src/search/searchinfoview.ts b/packages/ckeditor5-ui/src/search/searchinfoview.ts index a47624a7fa9..53584fccb32 100644 --- a/packages/ckeditor5-ui/src/search/searchinfoview.ts +++ b/packages/ckeditor5-ui/src/search/searchinfoview.ts @@ -7,6 +7,7 @@ * @module ui/search/searchinfoview */ +import type { SimpleFocusableView } from '../focuscycler'; import View from '../view'; /** @@ -14,7 +15,7 @@ import View from '../view'; * * @internal */ -export default class SearchInfoView extends View { +export default class SearchInfoView extends View implements SimpleFocusableView { /** * Controls whether the view is visible. * @@ -60,7 +61,8 @@ export default class SearchInfoView extends View { 'ck', 'ck-search__info', bind.if( 'isVisible', 'ck-hidden', value => !value ) - ] + ], + tabindex: -1 }, children: [ { @@ -82,4 +84,11 @@ export default class SearchInfoView extends View { ] } ); } + + /** + * Focuses the view + */ + public focus(): void { + this.element!.focus(); + } } diff --git a/packages/ckeditor5-ui/src/search/searchresultsview.ts b/packages/ckeditor5-ui/src/search/searchresultsview.ts index 9183b06c79a..b4f592d5efd 100644 --- a/packages/ckeditor5-ui/src/search/searchresultsview.ts +++ b/packages/ckeditor5-ui/src/search/searchresultsview.ts @@ -9,13 +9,20 @@ import View from '../view'; import type ViewCollection from '../viewcollection'; -import type { Locale } from '@ckeditor/ckeditor5-utils'; -import type { FocusableView } from '../focuscycler'; +import { FocusTracker, type Locale } from '@ckeditor/ckeditor5-utils'; +import { default as FocusCycler, type ViewWithFocusableChildren } from '../focuscycler'; /** * A sub-component of {@link module:ui/search/text/searchtextview~SearchTextView}. It hosts the filtered and the information views. */ -export default class SearchResultsView extends View implements FocusableView { +export default class SearchResultsView extends View implements ViewWithFocusableChildren { + /** + * Tracks information about the DOM focus in the view. + * + * @readonly + */ + public focusTracker: FocusTracker; + /** * The collection of the child views inside of the list item {@link #element}. * @@ -23,6 +30,13 @@ export default class SearchResultsView extends View implements FocusableView { */ public children: ViewCollection; + /** + * Provides the focus management (keyboard navigation) in the view. + * + * @readonly + */ + protected _focusCycler: FocusCycler; + /** * @inheritDoc */ @@ -30,6 +44,7 @@ export default class SearchResultsView extends View implements FocusableView { super( locale ); this.children = this.createCollection(); + this.focusTracker = new FocusTracker(); this.setTemplate( { tag: 'div', @@ -42,16 +57,42 @@ export default class SearchResultsView extends View implements FocusableView { }, children: this.children } ); + + this._focusCycler = new FocusCycler( { + focusables: this.children, + focusTracker: this.focusTracker + } ); } /** - * Focuses the first child view. + * @inheritDoc */ - public focus(): void { - const firstFocusableChild = this.children.find( ( child: any ) => typeof child.focus === 'function' ); + public override render(): void { + super.render(); - if ( firstFocusableChild ) { - ( firstFocusableChild as FocusableView ).focus(); + for ( const child of this.children ) { + this.focusTracker.add( child.element! ); } } + + /** + * Focuses the view. + */ + public focus(): void { + this._focusCycler.focusFirst(); + } + + /** + * Focuses the first child view. + */ + public focusFirst(): void { + this._focusCycler.focusFirst(); + } + + /** + * Focuses the last child view. + */ + public focusLast(): void { + this._focusCycler.focusLast(); + } } diff --git a/packages/ckeditor5-ui/src/search/text/searchtextview.ts b/packages/ckeditor5-ui/src/search/text/searchtextview.ts index 05714bb5c1c..2f867adf05b 100644 --- a/packages/ckeditor5-ui/src/search/text/searchtextview.ts +++ b/packages/ckeditor5-ui/src/search/text/searchtextview.ts @@ -122,7 +122,7 @@ export default class SearchTextView< * * @readonly */ - private _focusCycler: FocusCycler; + protected _focusCycler: FocusCycler; /** * The cached configuration object. diff --git a/packages/ckeditor5-ui/tests/focuscycler.js b/packages/ckeditor5-ui/tests/focuscycler.js index de3240ec7f7..4b86683a959 100644 --- a/packages/ckeditor5-ui/tests/focuscycler.js +++ b/packages/ckeditor5-ui/tests/focuscycler.js @@ -404,6 +404,25 @@ describe( 'FocusCycler', () => { cycler.focusNext(); } ).to.not.throw(); } ); + + it( 'fires an event while making full cycle back to the beginning', () => { + focusables = new ViewCollection( [ focusable(), focusable(), focusable() ] ); + cycler = new FocusCycler( { focusables, focusTracker } ); + focusTracker.focusedElement = focusables.get( 2 ).element; + + const forwardCycleSpy = sinon.spy(); + const backwardCycleSpy = sinon.spy(); + + cycler.on( 'forwardCycle', forwardCycleSpy ); + cycler.on( 'backwardCycle', backwardCycleSpy ); + + cycler.focusNext(); + focusTracker.focusedElement = focusables.get( 0 ).element; + cycler.focusNext(); + + sinon.assert.calledOnce( forwardCycleSpy ); + sinon.assert.notCalled( backwardCycleSpy ); + } ); } ); describe( 'focusPrevious()', () => { @@ -431,6 +450,25 @@ describe( 'FocusCycler', () => { cycler.focusPrevious(); } ).to.not.throw(); } ); + + it( 'fires an event while making full cycle back to the end', () => { + focusables = new ViewCollection( [ focusable(), focusable(), focusable() ] ); + cycler = new FocusCycler( { focusables, focusTracker } ); + focusTracker.focusedElement = focusables.get( 0 ).element; + + const forwardCycleSpy = sinon.spy(); + const backwardCycleSpy = sinon.spy(); + + cycler.on( 'forwardCycle', forwardCycleSpy ); + cycler.on( 'backwardCycle', backwardCycleSpy ); + + cycler.focusPrevious(); + focusTracker.focusedElement = focusables.get( 2 ).element; + cycler.focusPrevious(); + + sinon.assert.notCalled( forwardCycleSpy ); + sinon.assert.calledOnce( backwardCycleSpy ); + } ); } ); describe( 'keystrokes', () => { diff --git a/packages/ckeditor5-ui/tests/list/listview.js b/packages/ckeditor5-ui/tests/list/listview.js index 5465dc0b57e..fea82908cef 100644 --- a/packages/ckeditor5-ui/tests/list/listview.js +++ b/packages/ckeditor5-ui/tests/list/listview.js @@ -425,6 +425,24 @@ describe( 'ListView', () => { } ); } ); + describe( 'focusFirst()', () => { + it( 'focuses the first focusable item in DOM', () => { + // No children to focus. + view.focusFirst(); + + // The second child is focusable. + view.items.add( nonFocusable() ); + view.items.add( focusable() ); + view.items.add( focusable() ); + view.items.add( nonFocusable() ); + + const spy = sinon.spy( view.items.get( 1 ), 'focus' ); + view.focusFirst(); + + sinon.assert.calledOnce( spy ); + } ); + } ); + describe( 'focusLast()', () => { it( 'focuses the last focusable item in DOM', () => { // No children to focus. @@ -433,9 +451,10 @@ describe( 'ListView', () => { // The second child is focusable. view.items.add( nonFocusable() ); view.items.add( focusable() ); + view.items.add( focusable() ); view.items.add( nonFocusable() ); - const spy = sinon.spy( view.items.get( 1 ), 'focus' ); + const spy = sinon.spy( view.items.get( 2 ), 'focus' ); view.focusLast(); sinon.assert.calledOnce( spy ); diff --git a/packages/ckeditor5-ui/tests/search/searchinfoview.js b/packages/ckeditor5-ui/tests/search/searchinfoview.js index 616be942776..6918fb53db2 100644 --- a/packages/ckeditor5-ui/tests/search/searchinfoview.js +++ b/packages/ckeditor5-ui/tests/search/searchinfoview.js @@ -51,4 +51,14 @@ describe( 'SearchInfoView', () => { expect( view.element.innerHTML ).to.equal( 'bar' ); } ); } ); + + describe( 'focus()', () => { + it( 'should focus #element', () => { + const spy = sinon.spy( view.element, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); } ); diff --git a/packages/ckeditor5-ui/tests/search/searchresultsview.js b/packages/ckeditor5-ui/tests/search/searchresultsview.js index 1d3e5cee8ad..c9ffd3f261a 100644 --- a/packages/ckeditor5-ui/tests/search/searchresultsview.js +++ b/packages/ckeditor5-ui/tests/search/searchresultsview.js @@ -3,6 +3,8 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ +/* global document */ + import { Locale } from '@ckeditor/ckeditor5-utils'; import SearchResultsView from '../../src/search/searchresultsview'; @@ -18,11 +20,15 @@ describe( 'SearchResultsView', () => { locale = new Locale(); view = new SearchResultsView( locale ); + view.children.addMany( [ createNonFocusableView(), createFocusableView(), createFocusableView() ] ); view.render(); + + document.body.appendChild( view.element ); } ); afterEach( () => { view.destroy(); + view.element.remove(); } ); describe( 'constructor()', () => { @@ -47,16 +53,45 @@ describe( 'SearchResultsView', () => { } ); it( 'focuses first focusable view in #children', () => { - const firstChild = new View(); - const firstFocusableChild = new View(); + view.focus(); - firstFocusableChild.focus = sinon.spy(); + sinon.assert.calledOnce( view.children.get( 1 ).focus ); + } ); + } ); - view.children.addMany( [ firstChild, firstFocusableChild ] ); + describe( 'focusFirst()', () => { + it( 'focuses first focusable view in #children', () => { + view.focusFirst(); - view.focus(); + sinon.assert.calledOnce( view.children.get( 1 ).focus ); + } ); + } ); - sinon.assert.calledOnce( firstFocusableChild.focus ); + describe( 'focusLast()', () => { + it( 'focuses first focusable view in #children', () => { + view.focusLast(); + + sinon.assert.calledOnce( view.children.get( 2 ).focus ); } ); } ); + + function createFocusableView( name ) { + const view = createNonFocusableView(); + + view.name = name; + view.focus = () => view.element.focus(); + sinon.spy( view, 'focus' ); + + return view; + } + + function createNonFocusableView() { + const view = new View(); + + view.element = document.createElement( 'div' ); + view.element.textContent = 'foo'; + document.body.appendChild( view.element ); + + return view; + } } ); diff --git a/packages/ckeditor5-ui/tests/search/text/searchtextview.js b/packages/ckeditor5-ui/tests/search/text/searchtextview.js index 8d76ba43eb6..0d7bb373f84 100644 --- a/packages/ckeditor5-ui/tests/search/text/searchtextview.js +++ b/packages/ckeditor5-ui/tests/search/text/searchtextview.js @@ -413,7 +413,7 @@ describe( 'SearchTextView', () => { view.focusTracker.isFocused = true; view.focusTracker.focusedElement = view.queryView.element; - const spy = sinon.spy( view.resultsView, 'focus' ); + const spy = sinon.spy( view.resultsView, 'focusFirst' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault ); @@ -433,7 +433,7 @@ describe( 'SearchTextView', () => { view.focusTracker.isFocused = true; view.focusTracker.focusedElement = filteredView.element; - const spy = sinon.spy( view.resultsView, 'focus' ); + const spy = sinon.spy( view.resultsView, 'focusLast' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault );