Skip to content

Commit

Permalink
Merge pull request #16663 from ckeditor/ck/6047-annotations-a11y-v2
Browse files Browse the repository at this point in the history
Feature (ui): Introduced keystroke handler options in `FocusCycler#constructor()` to allow for fine-tuning of the class behavior.

Feature (ui): Implemented a new `FocusCycler#chain()` method to connect multiple focus cyclers and provide seamless keyboard navigation across complex user interfaces. 

Feature (utils): Introduced events filtering in `KeystrokeHandler#set()` to allow for fine-tuning of the helper's behavior.

Internal: Used `FocusCycler#chain()` in some of the current features implementation.
  • Loading branch information
scofalik authored Jul 30, 2024
2 parents 5221aca + 4632b00 commit 44fd251
Show file tree
Hide file tree
Showing 13 changed files with 927 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<FocusCyclerForwardCycleEvent>( 'forwardCycle', evt => {
this._focusCycler.focusNext();
evt.stop();
} );

view.fieldView.focusCycler.on<FocusCyclerBackwardCycleEvent>( 'backwardCycle', evt => {
this._focusCycler.focusPrevious();
evt.stop();
} );
this._focusCycler.chain( view.fieldView.focusCycler );
} );

[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<FocusCyclerForwardCycleEvent>( 'forwardCycle', evt => {
this._focusCycler.focusNext();
evt.stop();
} );

view.fieldView.focusCycler.on<FocusCyclerBackwardCycleEvent>( 'backwardCycle', evt => {
this._focusCycler.focusPrevious();
evt.stop();
} );
this._focusCycler.chain( view.fieldView.focusCycler );
} );

[
Expand Down
3 changes: 3 additions & 0 deletions packages/ckeditor5-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/ckeditor5-ui/src/bindings/draggableviewmixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default function DraggableViewMixin<Base extends Constructor<View>>( view
private _lastDraggingCoordinates: { x: number; y: number } = { x: 0, y: 0 };

/**
* @inheritdoc
* @inheritDoc
*/
constructor( ...args: Array<any> ) {
super( ...args );
Expand Down
20 changes: 1 addition & 19 deletions packages/ckeditor5-ui/src/dialog/dialogview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -652,23 +650,7 @@ export default class DialogView extends /* #__PURE__ */ DraggableViewMixin( View
this.focusTracker.add( focusable.element! );

if ( isViewWithFocusCycler( focusable ) ) {
this.listenTo<FocusCyclerForwardCycleEvent>( 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<FocusCyclerBackwardCycleEvent>( 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 );
}
} );
}
Expand Down
3 changes: 2 additions & 1 deletion packages/ckeditor5-ui/src/editorui/bodycollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
104 changes: 96 additions & 8 deletions packages/ckeditor5-ui/src/focuscycler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -115,6 +116,7 @@ export default class FocusCycler extends /* #__PURE__ */ EmitterMixin() {
focusables: ViewCollection<FocusableView>;
focusTracker: FocusTracker;
keystrokeHandler?: KeystrokeHandler;
keystrokeHandlerOptions?: KeystrokeHandlerOptions;
actions?: FocusCyclerActions;
} ) {
super();
Expand All @@ -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 );
}
}
}
Expand Down Expand Up @@ -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<FocusCyclerForwardCycleEvent>( 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<FocusCyclerBackwardCycleEvent>( 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.
*
Expand Down Expand Up @@ -362,12 +453,9 @@ export type ViewWithFocusCycler = FocusableView & {
focusCycler: FocusCycler;
};

export interface FocusCyclerActions {
focusFirst?: ArrayOrItem<string>;
focusLast?: ArrayOrItem<string>;
focusNext?: ArrayOrItem<string>;
focusPrevious?: ArrayOrItem<string>;
}
export type FocusCyclerActions = {
[ key in 'focusFirst' | 'focusLast' | 'focusPrevious' | 'focusNext' ]?: ArrayOrItem<string>
};

/**
* Fired when the focus cycler is about to move the focus from the last focusable item
Expand Down
Loading

0 comments on commit 44fd251

Please sign in to comment.