@ckeditor/ckeditor5-ui
Version:
The UI framework and standard UI library of CKEditor 5.
384 lines (383 loc) • 16.7 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module ui/focuscycler
*/
import { isVisible, EmitterMixin } from '@ckeditor/ckeditor5-utils';
/**
* A utility class that helps cycling over {@link module:ui/focuscycler~FocusableView focusable views} in a
* {@link module:ui/viewcollection~ViewCollection} when the focus is tracked by the
* {@link module:utils/focustracker~FocusTracker} instance. It helps implementing keyboard
* navigation in HTML forms, toolbars, lists and the like.
*
* To work properly it requires:
* * a collection of focusable (HTML `tabindex` attribute) views that implement the `focus()` method,
* * an associated focus tracker to determine which view is focused.
*
* A simple cycler setup can look like this:
*
* ```ts
* const focusables = new ViewCollection<FocusableView>();
* const focusTracker = new FocusTracker();
*
* // Add focusable views to the focus tracker.
* focusTracker.add( ... );
* ```
*
* Then, the cycler can be used manually:
*
* ```ts
* const cycler = new FocusCycler( { focusables, focusTracker } );
*
* // Will focus the first focusable view in #focusables.
* cycler.focusFirst();
*
* // Will log the next focusable item in #focusables.
* console.log( cycler.next );
* ```
*
* Alternatively, it can work side by side with the {@link module:utils/keystrokehandler~KeystrokeHandler}:
*
* ```ts
* const keystrokeHandler = new KeystrokeHandler();
*
* // Activate the keystroke handler.
* keystrokeHandler.listenTo( sourceOfEvents );
*
* const cycler = new FocusCycler( {
* focusables, focusTracker, keystrokeHandler,
* actions: {
* // When arrowup of arrowleft is detected by the #keystrokeHandler,
* // focusPrevious() will be called on the cycler.
* focusPrevious: [ 'arrowup', 'arrowleft' ],
* }
* } );
* ```
*
* Check out the {@glink framework/deep-dive/ui/focus-tracking "Deep dive into focus tracking"} guide to learn more.
*/
export class FocusCycler extends /* #__PURE__ */ EmitterMixin() {
/**
* A {@link module:ui/focuscycler~FocusableView focusable views} collection that the cycler operates on.
*/
focusables;
/**
* A focus tracker instance that the cycler uses to determine the current focus
* state in {@link #focusables}.
*/
focusTracker;
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}
* which can respond to certain keystrokes and cycle the focus.
*/
keystrokeHandler;
/**
* Actions that the cycler can take when a keystroke is pressed. Requires
* `options.keystrokeHandler` to be passed and working. When an action is
* performed, `preventDefault` and `stopPropagation` will be called on the event
* the keystroke fired in the DOM.
*
* ```ts
* actions: {
* // Will call #focusPrevious() when arrowleft or arrowup is pressed.
* focusPrevious: [ 'arrowleft', 'arrowup' ],
*
* // Will call #focusNext() when arrowdown is pressed.
* focusNext: 'arrowdown'
* }
* ```
*/
actions;
/**
* Creates an instance of the focus cycler utility.
*
* @param options Configuration options.
*/
constructor(options) {
super();
this.focusables = options.focusables;
this.focusTracker = options.focusTracker;
this.keystrokeHandler = options.keystrokeHandler;
this.actions = options.actions;
if (options.actions && options.keystrokeHandler) {
for (const methodName in options.actions) {
let actions = options.actions[methodName];
if (typeof actions == 'string') {
actions = [actions];
}
for (const keystroke of actions) {
options.keystrokeHandler.set(keystroke, (data, cancel) => {
this[methodName]();
cancel();
}, options.keystrokeHandlerOptions);
}
}
}
this.on('forwardCycle', () => this.focusFirst(), { priority: 'low' });
this.on('backwardCycle', () => this.focusLast(), { priority: 'low' });
}
/**
* Returns the first focusable view in {@link #focusables}.
* Returns `null` if there is none.
*
* **Note**: Hidden views (e.g. with `display: none`) are ignored.
*/
get first() {
return (this.focusables.find(isDomFocusable) || null);
}
/**
* Returns the last focusable view in {@link #focusables}.
* Returns `null` if there is none.
*
* **Note**: Hidden views (e.g. with `display: none`) are ignored.
*/
get last() {
return (this.focusables.filter(isDomFocusable).slice(-1)[0] || null);
}
/**
* Returns the next focusable view in {@link #focusables} based on {@link #current}.
* Returns `null` if there is none.
*
* **Note**: Hidden views (e.g. with `display: none`) are ignored.
*/
get next() {
return this._getDomFocusableItem(1);
}
/**
* Returns the previous focusable view in {@link #focusables} based on {@link #current}.
* Returns `null` if there is none.
*
* **Note**: Hidden views (e.g. with `display: none`) are ignored.
*/
get previous() {
return this._getDomFocusableItem(-1);
}
/**
* An index of the view in the {@link #focusables} which is focused according
* to {@link #focusTracker}. Returns `null` when there is no such view.
*/
get current() {
let index = null;
// There's no focused view in the focusables.
if (this.focusTracker.focusedElement === null) {
return null;
}
this.focusables.find((view, viewIndex) => {
const focused = view.element === this.focusTracker.focusedElement;
if (focused) {
index = viewIndex;
}
return focused;
});
return index;
}
/**
* Focuses the {@link #first} item in {@link #focusables}.
*
* **Note**: Hidden views (e.g. with `display: none`) are ignored.
*/
focusFirst() {
this._focus(this.first, 1);
}
/**
* Focuses the {@link #last} item in {@link #focusables}.
*
* **Note**: Hidden views (e.g. with `display: none`) are ignored.
*/
focusLast() {
this._focus(this.last, -1);
}
/**
* Focuses the {@link #next} item in {@link #focusables}.
*
* **Note**: Hidden views (e.g. with `display: none`) are ignored.
*/
focusNext() {
const next = this.next;
// If there's only one focusable item, we need to let the outside world know
// that the next cycle is about to happen. This may be useful
// e.g. if you want to move the focus to the parent focus cycler.
// Note that the focus is not actually moved in this case.
if (next && this.focusables.getIndex(next) === this.current) {
this.fire('forwardCycle');
return;
}
if (next === this.first) {
this.fire('forwardCycle');
}
else {
this._focus(next, 1);
}
}
/**
* Focuses the {@link #previous} item in {@link #focusables}.
*
* **Note**: Hidden views (e.g. with `display: none`) are ignored.
*/
focusPrevious() {
const previous = this.previous;
if (previous && this.focusables.getIndex(previous) === this.current) {
this.fire('backwardCycle');
return;
}
if (previous === this.last) {
this.fire('backwardCycle');
}
else {
this._focus(previous, -1);
}
}
/**
* 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.
*/
chain(chainedFocusCycler) {
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}.
*/
unchain(otherFocusCycler) {
this.stopListening(otherFocusCycler);
}
/**
* Focuses the given view if it exists.
*
* @param view The view to be focused
* @param direction The direction of the focus if the view has focusable children.
* @returns
*/
_focus(view, direction) {
// Don't fire focus events if the view is already focused.
// Such attempt may occur when cycling with only one focusable item:
// even though `focusNext()` method returns without changing focus,
// the `forwardCycle` event is fired, triggering the `focusFirst()` method.
if (view && this.focusTracker.focusedElement !== view.element) {
view.focus(direction);
}
}
/**
* Returns the next or previous focusable view in {@link #focusables} with respect
* to {@link #current}.
*
* @param step Either `1` for checking forward from {@link #current} or `-1` for checking backwards.
*/
_getDomFocusableItem(step) {
// Cache for speed.
const collectionLength = this.focusables.length;
if (!collectionLength) {
return null;
}
const current = this.current;
// Start from the beginning if no view is focused.
// https://github.com/ckeditor/ckeditor5-ui/issues/206
if (current === null) {
return this[step === 1 ? 'first' : 'last'];
}
// Note: If current is the only focusable view, it will also be returned for the given step.
let focusableItem = this.focusables.get(current);
// Cycle in both directions.
let index = (current + collectionLength + step) % collectionLength;
do {
const focusableItemCandidate = this.focusables.get(index);
if (isDomFocusable(focusableItemCandidate)) {
focusableItem = focusableItemCandidate;
break;
}
// Cycle in both directions.
index = (index + collectionLength + step) % collectionLength;
} while (index !== current);
return focusableItem;
}
}
/**
* Checks whether a view can be focused (has `focus()` method and is visible).
*
* @param view A view to be checked.
*/
function isDomFocusable(view) {
return isFocusable(view) && isVisible(view.element);
}
/**
* Checks whether a view is {@link ~FocusableView}.
*
* @param view A view to be checked.
*/
export function isFocusable(view) {
return !!('focus' in view && typeof view.focus == 'function');
}
/**
* Checks whether a view is an instance of {@link ~ViewWithFocusCycler}.
*
* @param view A view to be checked.
*/
export function isViewWithFocusCycler(view) {
return isFocusable(view) && 'focusCycler' in view && view.focusCycler instanceof FocusCycler;
}