Skip to content

Commit

Permalink
Allowed for linking FocusTracker instances together and propagate the…
Browse files Browse the repository at this point in the history
… focus state.
  • Loading branch information
oleq committed Oct 3, 2024
1 parent 915c3a7 commit 900ac8e
Show file tree
Hide file tree
Showing 20 changed files with 1,182 additions and 150 deletions.
3 changes: 2 additions & 1 deletion docs/_snippets/framework/ui/ui-toolbar-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
const locale = new Locale();

const text = new View();
text.element = document.createTextNode( 'Toolbar text' );
text.element = document.createElement( 'span' );
text.element.innerHTML = 'Toolbar text';

const toolbarText = new ToolbarView( locale );
toolbarText.items.add( text );
Expand Down
3 changes: 2 additions & 1 deletion packages/ckeditor5-theme-lark/tests/manual/theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ class TextView extends View {
constructor() {
super();

this.element = document.createTextNode( 'Sample text' );
this.element = document.createElement( 'span' );
this.element.innerHTML = 'Sample text';
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/ckeditor5-ui/src/dropdown/dropdownview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export default class DropdownView extends View<HTMLDivElement> {

this.keystrokes = new KeystrokeHandler();
this.focusTracker = new FocusTracker();
// this.focusTracker._label = 'DropdownView';

this.setTemplate( {
tag: 'div',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export default class DropdownMenuNestedMenuView extends View implements Focusabl

this.keystrokes = new KeystrokeHandler();
this.focusTracker = new FocusTracker();
// this.focusTracker._label = 'DropdownMenuNestedMenuView';

this.buttonView = new DropdownMenuButtonView( locale );
this.buttonView.delegate( 'mouseenter' ).to( this );
Expand Down Expand Up @@ -209,6 +210,7 @@ export default class DropdownMenuNestedMenuView extends View implements Focusabl

this.focusTracker.add( this.buttonView.element! );
this.focusTracker.add( this.panelView.element! );
this.focusTracker.add( this.listView );

// Listen for keystrokes coming from within #element.
this.keystrokes.listenTo( this.element! );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { DropdownRootMenuBehaviors } from './dropdownmenubehaviors.js';
import type BodyCollection from '../../editorui/bodycollection.js';
import type { DropdownMenuDefinition } from './utils.js';

import type { Locale, BaseEvent } from '@ckeditor/ckeditor5-utils';
import { type Locale, type BaseEvent } from '@ckeditor/ckeditor5-utils';

/**
* Creates and manages a multi-level menu UI structure, suitable to be used inside dropdown components.
Expand Down Expand Up @@ -121,6 +121,7 @@ export default class DropdownMenuRootListView extends DropdownMenuListView {

this._bodyCollection = bodyCollection;
this._definition = definition;
// this.focusTracker._label = 'DropdownMenuRootListView';

this.set( 'menuPanelClass', undefined );
}
Expand Down
3 changes: 3 additions & 0 deletions packages/ckeditor5-ui/src/dropdown/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export function addMenuToDropdown(
ariaLabel?: string;
} = {} ): void {
dropdownView.menuView = new DropdownMenuRootListView( dropdownView.locale!, body, definition );
dropdownView.focusTracker.add( dropdownView.menuView );

if ( dropdownView.isOpen ) {
addMenuToOpenDropdown( dropdownView, options );
Expand All @@ -218,6 +219,8 @@ function addMenuToOpenDropdown(
const dropdownMenuRootListView = dropdownView.menuView!;
const t = dropdownView.locale!.t;

// dropdownView.focusTracker._label = 'MenuDropdownView';

dropdownMenuRootListView.delegate( 'menu:execute' ).to( dropdownView, 'execute' );
dropdownMenuRootListView.listenTo( dropdownView, 'change:isOpen', ( evt, name, isOpen ) => {
if ( !isOpen ) {
Expand Down
5 changes: 3 additions & 2 deletions packages/ckeditor5-ui/src/editorui/editorui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export default abstract class EditorUI extends /* #__PURE__ */ ObservableMixin()
this.editor = editor;
this.componentFactory = new ComponentFactory( editor );
this.focusTracker = new FocusTracker();
// this.focusTracker._label = 'EditorUI';
this.tooltipManager = new TooltipManager( editor );
this.poweredBy = new PoweredBy( editor );
this.ariaLiveAnnouncer = new AriaLiveAnnouncer( editor );
Expand Down Expand Up @@ -306,11 +307,11 @@ export default abstract class EditorUI extends /* #__PURE__ */ ObservableMixin()
*/
public addToolbar( toolbarView: ToolbarView, options: FocusableToolbarOptions = {} ): void {
if ( toolbarView.isRendered ) {
this.focusTracker.add( toolbarView.element! );
this.focusTracker.add( toolbarView );
this.editor.keystrokes.listenTo( toolbarView.element! );
} else {
toolbarView.once<UIViewRenderEvent>( 'render', () => {
this.focusTracker.add( toolbarView.element! );
this.focusTracker.add( toolbarView );
this.editor.keystrokes.listenTo( toolbarView.element! );
} );
}
Expand Down
1 change: 1 addition & 0 deletions packages/ckeditor5-ui/src/list/listview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export default class ListView extends View<HTMLUListElement> implements Dropdown
this.items = this.createCollection();
this.focusTracker = new FocusTracker();
this.keystrokes = new KeystrokeHandler();
// this.focusTracker._label = 'ListView';

this._focusCycler = new FocusCycler( {
focusables: this.focusables,
Expand Down
2 changes: 1 addition & 1 deletion packages/ckeditor5-ui/src/search/text/searchtextview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export default class SearchTextView<
const stopPropagation = ( data: Event ) => data.stopPropagation();

for ( const focusableChild of this.focusableChildren ) {
this.focusTracker.add( focusableChild.element as Element );
this.focusTracker.add( focusableChild.element as HTMLElement );
}

// Start listening for the keystrokes coming from #element.
Expand Down
4 changes: 3 additions & 1 deletion packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,12 @@ export default class BalloonToolbar extends Plugin {
this._balloonConfig = normalizeToolbarConfig( editor.config.get( 'balloonToolbar' ) );
this.toolbarView = this._createToolbarView();
this.focusTracker = new FocusTracker();
// this.focusTracker._label = 'BalloonToolbar';
// this.toolbarView.focusTracker._label = 'BalloonToolbar#ToolbarView';

// Track focusable elements in the toolbar and the editable elements.
this._trackFocusableEditableElements();
this.focusTracker.add( this.toolbarView.element! );
this.focusTracker.add( this.toolbarView );

// Register the toolbar so it becomes available for Alt+F10 and Esc navigation.
editor.ui.addToolbar( this.toolbarView, {
Expand Down
8 changes: 5 additions & 3 deletions packages/ckeditor5-ui/src/toolbar/toolbarview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ export default class ToolbarView extends View implements DropdownPanelFocusable
this.focusTracker = new FocusTracker();
this.keystrokes = new KeystrokeHandler();

// this.focusTracker._label = 'ToolbarView';

this.set( 'class', undefined );
this.set( 'isCompact', false );

Expand Down Expand Up @@ -260,15 +262,15 @@ export default class ToolbarView extends View implements DropdownPanelFocusable

// Children added before rendering should be known to the #focusTracker.
for ( const item of this.items ) {
this.focusTracker.add( item.element! );
this.focusTracker.add( item );
}

this.items.on<CollectionAddEvent<View>>( 'add', ( evt, item ) => {
this.focusTracker.add( item.element! );
this.focusTracker.add( item );
} );

this.items.on<CollectionRemoveEvent<View>>( 'remove', ( evt, item ) => {
this.focusTracker.remove( item.element! );
this.focusTracker.remove( item );
} );

// Start listening for the keystrokes coming from #element.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ describe( 'DropdownMenuNestedMenuView', () => {
sinon.assert.calledWithExactly( focusTrackerAddSpy.secondCall, menuView.panelView.element );
} );

// https://github.com/cksource/ckeditor5-commercial/issues/6633
it( 'should add the #listView to the focus tracker to allow for linking focus trackers and sharing state of nested menus', () => {
const focusTrackerAddSpy = sinon.spy( menuView.focusTracker, 'add' );

menuView.render();

sinon.assert.calledWithExactly( focusTrackerAddSpy.thirdCall, menuView.listView );
} );

it( 'should start listening to keystrokes', () => {
const keystrokeHandlerAddSpy = sinon.spy( menuView.keystrokes, 'listenTo' );

Expand Down
10 changes: 10 additions & 0 deletions packages/ckeditor5-ui/tests/dropdown/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,16 @@ describe( 'utils', () => {
expect( dropdownView.menuView.render.calledOnce ).to.be.true;
} );

it( 'should add the menu view to dropdown\'s focus tracker to allow for linking focus trackers and keeping track of the focus ' +
'when it goes to sub-menus in other DOM sub-trees',
() => {
const addSpy = sinon.spy( dropdownView.focusTracker, 'add' );

addMenuToDropdown( dropdownView, body, definition );

sinon.assert.calledWithExactly( addSpy, dropdownView.menuView );
} );

it( 'should focus dropdown menu view after dropdown is opened', () => {
addMenuToDropdown( dropdownView, body, definition );

Expand Down
10 changes: 5 additions & 5 deletions packages/ckeditor5-ui/tests/editorui/editorui.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ describe( 'EditorUI', () => {
} );

it( 'should reset editables array', () => {
ui.setEditableElement( 'foo', {} );
ui.setEditableElement( 'bar', {} );
ui.setEditableElement( 'foo', document.createElement( 'div' ) );
ui.setEditableElement( 'bar', document.createElement( 'div' ) );

expect( [ ...ui.getEditableElementsNames() ] ).to.deep.equal( [ 'foo', 'bar' ] );

Expand Down Expand Up @@ -530,13 +530,13 @@ describe( 'EditorUI', () => {
} );

describe( 'for a ToolbarView that has already been rendered', () => {
it( 'adds ToolbarView#element to the EditorUI#focusTracker', () => {
it( 'adds ToolbarView to the EditorUI#focusTracker', () => {
const spy = testUtils.sinon.spy( ui.focusTracker, 'add' );
toolbar.render();

ui.addToolbar( toolbar );

sinon.assert.calledOnce( spy );
sinon.assert.calledOnceWithExactly( spy, toolbar );
} );

it( 'adds ToolbarView#element to Editor#keystokeHandler', () => {
Expand All @@ -559,7 +559,7 @@ describe( 'EditorUI', () => {
await new Promise( resolve => {
toolbar.once( 'render', () => {
sinon.assert.calledOnce( spy );
sinon.assert.calledOnce( spy2 );
sinon.assert.calledOnceWithExactly( spy2, toolbar );

resolve();
} );
Expand Down
44 changes: 28 additions & 16 deletions packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,13 @@ describe( 'BalloonToolbar', () => {
expect( balloonToolbar.focusTracker.isFocused ).to.true;
} );

// https://github.com/cksource/ckeditor5-commercial/issues/6633
it( 'should track the ToolbarView instance (not just its element) to allow using complex toolbar items scattered across DOM ' +
'sub-trees and keep track of the focus',
() => {
expect( balloonToolbar.focusTracker.externalViews ).to.include( balloonToolbar.toolbarView );
} );

it( 'it should track the focus of the toolbarView#element', () => {
expect( balloonToolbar.focusTracker.isFocused ).to.false;

Expand Down Expand Up @@ -828,7 +835,7 @@ describe( 'BalloonToolbar', () => {
} );

describe( 'MultiRoot editor integration', () => {
let rootsElements, addEditableOnRootAdd;
let rootsElements, addEditableOnRootAdd, focusHolder;

beforeEach( async () => {
addEditableOnRootAdd = true;
Expand All @@ -849,6 +856,9 @@ describe( 'BalloonToolbar', () => {

editor = await createMultiRootEditor();
balloonToolbar = editor.plugins.get( BalloonToolbar );

focusHolder = document.createElement( 'input' );
document.body.appendChild( focusHolder );
} );

afterEach( async () => {
Expand All @@ -858,6 +868,8 @@ describe( 'BalloonToolbar', () => {

await editor.destroy();
editor = null;

focusHolder.remove();
} );

it( 'should create plugin instance', () => {
Expand All @@ -877,11 +889,11 @@ describe( 'BalloonToolbar', () => {
for ( const editableName of editables ) {
const editableElement = editor.ui.getEditableElement( editableName );

editableElement.dispatchEvent( new Event( 'focus' ) );
editableElement.focus();
clock.tick( 50 );
expect( balloonToolbar.focusTracker.isFocused ).to.true;

editableElement.dispatchEvent( new Event( 'blur' ) );
focusHolder.focus();
clock.tick( 50 );
expect( balloonToolbar.focusTracker.isFocused ).to.false;
}
Expand All @@ -893,24 +905,24 @@ describe( 'BalloonToolbar', () => {
const clock = sinon.useFakeTimers();

expect( balloonToolbar.focusTracker.isFocused ).to.false;
expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 );
expect( balloonToolbar.focusTracker.elements.length ).to.be.equal( 4 );

editor.addRoot( 'dynamicRoot' );

// Check if newly added editable is tracked in focus tracker.
expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 5 );
expect( balloonToolbar.focusTracker.elements.length ).to.be.equal( 5 );

// Check if element is added to focus tracker.
const editableElement = editor.ui.getEditableElement( 'dynamicRoot' );
expect( balloonToolbar.focusTracker._elements ).contain( editableElement );

// Watch focus and blur events.
editableElement.dispatchEvent( new Event( 'focus' ) );
editableElement.focus();
clock.tick( 50 );

expect( balloonToolbar.focusTracker.isFocused ).to.true;

editableElement.dispatchEvent( new Event( 'blur' ) );
focusHolder.focus();
clock.tick( 50 );
expect( balloonToolbar.focusTracker.isFocused ).to.false;

Expand All @@ -922,21 +934,21 @@ describe( 'BalloonToolbar', () => {
const clock = sinon.useFakeTimers();

expect( balloonToolbar.focusTracker.isFocused ).to.false;
expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 );
expect( balloonToolbar.focusTracker.elements.length ).to.be.equal( 4 );

editor.addRoot( 'dynamicRoot' );
const editableElement = editor.ui.getEditableElement( 'dynamicRoot' );

// Check if newly added editable is tracked in focus tracker.
expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 5 );
expect( balloonToolbar.focusTracker.elements.length ).to.be.equal( 5 );

editor.detachRoot( 'dynamicRoot' );

// Check if element is removed from focus tracker.
expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 );
expect( balloonToolbar.focusTracker.elements.length ).to.be.equal( 4 );

// Focus is no longer tracked.
editableElement.dispatchEvent( new Event( 'focus' ) );
editableElement.focus();
clock.tick( 50 );

expect( balloonToolbar.focusTracker.isFocused ).to.false;
Expand All @@ -950,29 +962,29 @@ describe( 'BalloonToolbar', () => {
addEditableOnRootAdd = false;

expect( balloonToolbar.focusTracker.isFocused ).to.false;
expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 );
expect( balloonToolbar.focusTracker.elements.length ).to.be.equal( 4 );

editor.addRoot( 'dynamicRoot' );
const root = editor.model.document.getRoot( 'dynamicRoot' );

// Editable is not yet attached
expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 );
expect( balloonToolbar.focusTracker.elements.length ).to.be.equal( 4 );

// Focus is no longer tracked.
const editableElement = editor.createEditable( root );

global.document.body.appendChild( editableElement );
expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 5 );
expect( balloonToolbar.focusTracker.elements.length ).to.be.equal( 5 );

// Lets test focus
editableElement.dispatchEvent( new Event( 'focus' ) );
editableElement.focus();
clock.tick( 50 );

expect( balloonToolbar.focusTracker.isFocused ).to.true;

// Detach editable element
editor.detachEditable( root );
expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 );
expect( balloonToolbar.focusTracker.elements.length ).to.be.equal( 4 );

editableElement.remove();
clock.restore();
Expand Down
Loading

0 comments on commit 900ac8e

Please sign in to comment.