From 900ac8e616c15708c1637e49fac1830c29abd31f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksander=20Nowodzi=C5=84ski?= Date: Wed, 2 Oct 2024 16:19:21 +0200 Subject: [PATCH] Allowed for linking FocusTracker instances together and propagate the focus state. --- .../_snippets/framework/ui/ui-toolbar-text.js | 3 +- .../tests/manual/theme.js | 3 +- .../ckeditor5-ui/src/dropdown/dropdownview.ts | 1 + .../menu/dropdownmenunestedmenuview.ts | 2 + .../dropdown/menu/dropdownmenurootlistview.ts | 3 +- packages/ckeditor5-ui/src/dropdown/utils.ts | 3 + .../ckeditor5-ui/src/editorui/editorui.ts | 5 +- packages/ckeditor5-ui/src/list/listview.ts | 1 + .../src/search/text/searchtextview.ts | 2 +- .../src/toolbar/balloon/balloontoolbar.ts | 4 +- .../ckeditor5-ui/src/toolbar/toolbarview.ts | 8 +- .../menu/dropdownmenunestedmenuview.js | 9 + packages/ckeditor5-ui/tests/dropdown/utils.js | 10 + .../ckeditor5-ui/tests/editorui/editorui.js | 10 +- .../tests/toolbar/balloon/balloontoolbar.js | 44 +- .../ckeditor5-ui/tests/toolbar/toolbarview.js | 18 +- packages/ckeditor5-utils/package.json | 3 +- packages/ckeditor5-utils/src/focustracker.ts | 281 +++++- packages/ckeditor5-utils/src/index.ts | 2 +- .../ckeditor5-utils/tests/focustracker.js | 920 ++++++++++++++++-- 20 files changed, 1182 insertions(+), 150 deletions(-) diff --git a/docs/_snippets/framework/ui/ui-toolbar-text.js b/docs/_snippets/framework/ui/ui-toolbar-text.js index 6c43c3b58c7..15e386084ae 100644 --- a/docs/_snippets/framework/ui/ui-toolbar-text.js +++ b/docs/_snippets/framework/ui/ui-toolbar-text.js @@ -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 ); diff --git a/packages/ckeditor5-theme-lark/tests/manual/theme.js b/packages/ckeditor5-theme-lark/tests/manual/theme.js index 7db630b8fc0..3a1197a0ed1 100644 --- a/packages/ckeditor5-theme-lark/tests/manual/theme.js +++ b/packages/ckeditor5-theme-lark/tests/manual/theme.js @@ -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'; } } diff --git a/packages/ckeditor5-ui/src/dropdown/dropdownview.ts b/packages/ckeditor5-ui/src/dropdown/dropdownview.ts index a33b00e463d..ea0239f3c0d 100644 --- a/packages/ckeditor5-ui/src/dropdown/dropdownview.ts +++ b/packages/ckeditor5-ui/src/dropdown/dropdownview.ts @@ -223,6 +223,7 @@ export default class DropdownView extends View { this.keystrokes = new KeystrokeHandler(); this.focusTracker = new FocusTracker(); + // this.focusTracker._label = 'DropdownView'; this.setTemplate( { tag: 'div', diff --git a/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenunestedmenuview.ts b/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenunestedmenuview.ts index 701d04c18a1..d227a2e54a1 100644 --- a/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenunestedmenuview.ts +++ b/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenunestedmenuview.ts @@ -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 ); @@ -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! ); diff --git a/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenurootlistview.ts b/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenurootlistview.ts index 7ad075a955c..54fbc0e4568 100644 --- a/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenurootlistview.ts +++ b/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenurootlistview.ts @@ -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. @@ -121,6 +121,7 @@ export default class DropdownMenuRootListView extends DropdownMenuListView { this._bodyCollection = bodyCollection; this._definition = definition; + // this.focusTracker._label = 'DropdownMenuRootListView'; this.set( 'menuPanelClass', undefined ); } diff --git a/packages/ckeditor5-ui/src/dropdown/utils.ts b/packages/ckeditor5-ui/src/dropdown/utils.ts index 02918783076..8921687f502 100644 --- a/packages/ckeditor5-ui/src/dropdown/utils.ts +++ b/packages/ckeditor5-ui/src/dropdown/utils.ts @@ -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 ); @@ -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 ) { diff --git a/packages/ckeditor5-ui/src/editorui/editorui.ts b/packages/ckeditor5-ui/src/editorui/editorui.ts index 21851581a36..965473351f3 100644 --- a/packages/ckeditor5-ui/src/editorui/editorui.ts +++ b/packages/ckeditor5-ui/src/editorui/editorui.ts @@ -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 ); @@ -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( 'render', () => { - this.focusTracker.add( toolbarView.element! ); + this.focusTracker.add( toolbarView ); this.editor.keystrokes.listenTo( toolbarView.element! ); } ); } diff --git a/packages/ckeditor5-ui/src/list/listview.ts b/packages/ckeditor5-ui/src/list/listview.ts index f6a0766fe67..2765cdee6b0 100644 --- a/packages/ckeditor5-ui/src/list/listview.ts +++ b/packages/ckeditor5-ui/src/list/listview.ts @@ -96,6 +96,7 @@ export default class ListView extends View 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, diff --git a/packages/ckeditor5-ui/src/search/text/searchtextview.ts b/packages/ckeditor5-ui/src/search/text/searchtextview.ts index cec86550732..22b212674fc 100644 --- a/packages/ckeditor5-ui/src/search/text/searchtextview.ts +++ b/packages/ckeditor5-ui/src/search/text/searchtextview.ts @@ -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. diff --git a/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts b/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts index 42e59c0d3b2..25ca2decdb8 100644 --- a/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts +++ b/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts @@ -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, { diff --git a/packages/ckeditor5-ui/src/toolbar/toolbarview.ts b/packages/ckeditor5-ui/src/toolbar/toolbarview.ts index 728b49fa645..1ba64cf4b85 100644 --- a/packages/ckeditor5-ui/src/toolbar/toolbarview.ts +++ b/packages/ckeditor5-ui/src/toolbar/toolbarview.ts @@ -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 ); @@ -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>( 'add', ( evt, item ) => { - this.focusTracker.add( item.element! ); + this.focusTracker.add( item ); } ); this.items.on>( 'remove', ( evt, item ) => { - this.focusTracker.remove( item.element! ); + this.focusTracker.remove( item ); } ); // Start listening for the keystrokes coming from #element. diff --git a/packages/ckeditor5-ui/tests/dropdown/menu/dropdownmenunestedmenuview.js b/packages/ckeditor5-ui/tests/dropdown/menu/dropdownmenunestedmenuview.js index e608341d89c..d5d54d9559c 100644 --- a/packages/ckeditor5-ui/tests/dropdown/menu/dropdownmenunestedmenuview.js +++ b/packages/ckeditor5-ui/tests/dropdown/menu/dropdownmenunestedmenuview.js @@ -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' ); diff --git a/packages/ckeditor5-ui/tests/dropdown/utils.js b/packages/ckeditor5-ui/tests/dropdown/utils.js index 9438ac1796c..10a6a845b6c 100644 --- a/packages/ckeditor5-ui/tests/dropdown/utils.js +++ b/packages/ckeditor5-ui/tests/dropdown/utils.js @@ -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 ); diff --git a/packages/ckeditor5-ui/tests/editorui/editorui.js b/packages/ckeditor5-ui/tests/editorui/editorui.js index db532e87c99..f36012fe6d8 100644 --- a/packages/ckeditor5-ui/tests/editorui/editorui.js +++ b/packages/ckeditor5-ui/tests/editorui/editorui.js @@ -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' ] ); @@ -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', () => { @@ -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(); } ); diff --git a/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js b/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js index d624521bc89..729a727e81b 100644 --- a/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js +++ b/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js @@ -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; @@ -828,7 +835,7 @@ describe( 'BalloonToolbar', () => { } ); describe( 'MultiRoot editor integration', () => { - let rootsElements, addEditableOnRootAdd; + let rootsElements, addEditableOnRootAdd, focusHolder; beforeEach( async () => { addEditableOnRootAdd = true; @@ -849,6 +856,9 @@ describe( 'BalloonToolbar', () => { editor = await createMultiRootEditor(); balloonToolbar = editor.plugins.get( BalloonToolbar ); + + focusHolder = document.createElement( 'input' ); + document.body.appendChild( focusHolder ); } ); afterEach( async () => { @@ -858,6 +868,8 @@ describe( 'BalloonToolbar', () => { await editor.destroy(); editor = null; + + focusHolder.remove(); } ); it( 'should create plugin instance', () => { @@ -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; } @@ -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; @@ -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; @@ -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(); diff --git a/packages/ckeditor5-ui/tests/toolbar/toolbarview.js b/packages/ckeditor5-ui/tests/toolbar/toolbarview.js index b0737f1e0fd..ebc37908f98 100644 --- a/packages/ckeditor5-ui/tests/toolbar/toolbarview.js +++ b/packages/ckeditor5-ui/tests/toolbar/toolbarview.js @@ -256,28 +256,36 @@ describe( 'ToolbarView', () => { view.render(); - sinon.assert.calledOnce( spyAdd ); + sinon.assert.calledOnceWithExactly( spyAdd, view.element ); sinon.assert.notCalled( spyRemove ); view.destroy(); } ); - it( 'registers #items in #focusTracker', () => { + // https://github.com/cksource/ckeditor5-commercial/issues/6633 + it( 'registers #items in #focusTracker as View instances (not just DOM elements) to alow for complex Views scattered across ' + + 'multiple DOM sub-trees', + () => { const view = new ToolbarView( locale ); const spyAdd = sinon.spy( view.focusTracker, 'add' ); const spyRemove = sinon.spy( view.focusTracker, 'remove' ); - view.items.add( focusable() ); - view.items.add( focusable() ); + const focusableViewA = focusable(); + const focusableViewB = focusable(); + + view.items.add( focusableViewA ); + view.items.add( focusableViewB ); sinon.assert.notCalled( spyAdd ); view.render(); // 2 for items and 1 for toolbar itself. sinon.assert.calledThrice( spyAdd ); + sinon.assert.calledWithExactly( spyAdd.secondCall, focusableViewA ); + sinon.assert.calledWithExactly( spyAdd.thirdCall, focusableViewB ); view.items.remove( 1 ); - sinon.assert.calledOnce( spyRemove ); + sinon.assert.calledOnceWithExactly( spyRemove, focusableViewB ); view.destroy(); } ); diff --git a/packages/ckeditor5-utils/package.json b/packages/ckeditor5-utils/package.json index dac17d2839b..75cec8ba3d2 100644 --- a/packages/ckeditor5-utils/package.json +++ b/packages/ckeditor5-utils/package.json @@ -12,7 +12,8 @@ "type": "module", "main": "src/index.ts", "dependencies": { - "lodash-es": "4.17.21" + "lodash-es": "4.17.21", + "@ckeditor/ckeditor5-ui": "43.2.0" }, "devDependencies": { "@ckeditor/ckeditor5-build-classic": "43.2.0", diff --git a/packages/ckeditor5-utils/src/focustracker.ts b/packages/ckeditor5-utils/src/focustracker.ts index 296041a24c4..b13715bae96 100644 --- a/packages/ckeditor5-utils/src/focustracker.ts +++ b/packages/ckeditor5-utils/src/focustracker.ts @@ -12,9 +12,11 @@ import DomEmitterMixin from './dom/emittermixin.js'; import ObservableMixin from './observablemixin.js'; import CKEditorError from './ckeditorerror.js'; +import { type View } from '@ckeditor/ckeditor5-ui'; +import { isElement as _isElement } from 'lodash-es'; /** - * Allows observing a group of `Element`s whether at least one of them is focused. + * Allows observing a group of DOM `Element`s whether at least one of them (or their child) is focused. * * Used by the {@link module:core/editor/editor~Editor} in order to track whether the focus is still within the application, * or were used outside of its UI. @@ -27,7 +29,7 @@ import CKEditorError from './ckeditorerror.js'; */ export default class FocusTracker extends /* #__PURE__ */ DomEmitterMixin( /* #__PURE__ */ ObservableMixin() ) { /** - * True when one of the registered elements is focused. + * True when one of the registered {@link #elements} or {@link #externalFocusTrackers} is focused. * * @readonly * @observable @@ -37,10 +39,11 @@ export default class FocusTracker extends /* #__PURE__ */ DomEmitterMixin( /* #_ /** * The currently focused element. * - * While {@link #isFocused `isFocused`} remains `true`, the focus can - * move between different UI elements. This property tracks those + * While {@link #isFocused `isFocused`} remains `true`, the focus can move between different UI elements. This property tracks those * elements and tells which one is currently focused. * + * **Note**: The values of this property are restricted to {@link #elements} or elements registered in {@link #externalFocusTrackers}. + * * @readonly * @observable */ @@ -53,29 +56,103 @@ export default class FocusTracker extends /* #__PURE__ */ DomEmitterMixin( /* #_ */ public _elements: Set = new Set(); + /** + * List of external focus trackers that contribute to the state of this focus tracker. + * + * @internal + */ + public _externalViews: Set = new Set(); + /** * Event loop timeout. */ private _nextEventLoopTimeout: ReturnType | null = null; + // @if CK_DEBUG_FOCUSTRACKER // public _label?: string; + constructor() { super(); this.set( 'isFocused', false ); this.set( 'focusedElement', null ); + + // @if CK_DEBUG_FOCUSTRACKER // FocusTracker._instances.push( this ); } /** - * List of registered elements. + * List of registered DOM elements. + * + * **Note**: The list does do not include elements from {@link #externalFocusTrackers}. */ public get elements(): Array { return Array.from( this._elements.values() ); } /** - * Starts tracking the specified element. + * List of external focusable views that contribute to the state of this focus tracker. See {@link #add} to learn more. + */ + public get externalViews(): Array { + return Array.from( this._externalViews.values() ); + } + + /** + * Starts tracking a specified DOM element or a {@link module:ui/view~View} instance. + * + * * If a DOM element is passed, the focus tracker listens to the `focus` and `blur` events on this element. + * Tracked elements are listed in {@link #elements}. + * * If a {@link module:ui/view~View} instance is passed that has a `FocusTracker` instance ({@link ~ViewWithFocusTracker}), + * the external focus tracker's state ({@link #isFocused}, {@link #focusedElement}) starts contributing to the current tracker instance. + * This allows for increasing the "reach" of a focus tracker instance, by connecting two or more focus trackers together when DOM + * elements they track are located in different subtrees in DOM. External focus trackers are listed in {@link #externalFocusTrackers}. + * * If a {@link module:ui/view~View} instance is passed that has no `FocusTracker` (**not** a {@link ~ViewWithFocusTracker}), + * its {@link module:ui/view~View#element} is used to track focus like any other DOM element. + */ + public add( elementOrView: Element | View ): void { + if ( isElement( elementOrView ) ) { + this._addElement( elementOrView ); + } else { + if ( isViewWithFocusTracker( elementOrView ) ) { + this._addView( elementOrView ); + } else { + if ( !elementOrView.element ) { + /** + * The {@link module:ui/view~View} added to the {@link module:utils/focustracker~FocusTracker} does not have an + * {@link module:ui/view~View#element}. Make sure the view is {@link module:ui/view~View#render} before adding + * it to the focus tracker. + * + * @error focustracker-add-view-missing-element + */ + throw new CKEditorError( 'focustracker-add-view-missing-element', { + focusTracker: this, + view: elementOrView + } ); + } + + this._addElement( elementOrView.element ); + } + } + } + + /** + * Stops tracking focus in the specified DOM element or a {@link module:ui/view~View view instance}. See {@link #add} to learn more. + */ + public remove( elementOrView: Element | View ): void { + if ( isElement( elementOrView ) ) { + this._removeElement( elementOrView ); + } else { + if ( isViewWithFocusTracker( elementOrView ) ) { + this._removeView( elementOrView ); + } else { + // Assuming that if the view was successfully added, it must have come with an existing #element. + this._removeElement( elementOrView.element! ); + } + } + } + + /** + * Adds a DOM element to the focus tracker and starts listening to the `focus` and `blur` events on it. */ - public add( element: Element ): void { + private _addElement( element: Element ): void { if ( this._elements.has( element ) ) { /** * This element is already tracked by {@link module:utils/focustracker~FocusTracker}. @@ -85,23 +162,77 @@ export default class FocusTracker extends /* #__PURE__ */ DomEmitterMixin( /* #_ throw new CKEditorError( 'focustracker-add-element-already-exist', this ); } - this.listenTo( element, 'focus', () => this._focus( element ), { useCapture: true } ); - this.listenTo( element, 'blur', () => this._blur(), { useCapture: true } ); + this.listenTo( element, 'focus', () => { + // @if CK_DEBUG_FOCUSTRACKER // console.log( `"${ getName( this ) }": Focus with useCapture on DOM element` ); + + const externalFocusedViewInSubtree = this.externalViews.find( view => isExternalViewSubtreeFocused( element, view ) ); + + if ( externalFocusedViewInSubtree ) { + this._focus( externalFocusedViewInSubtree.element! ); + } else { + this._focus( element ); + } + }, { useCapture: true } ); + + this.listenTo( element, 'blur', () => { + // @if CK_DEBUG_FOCUSTRACKER // console.log( `"${ getName( this ) }": Blur with useCapture on DOM element` ); + + this._blur(); + }, { useCapture: true } ); + this._elements.add( element ); } /** - * Stops tracking the specified element and stops listening on this element. + * Removes a DOM element from the focus tracker. */ - public remove( element: Element ): void { + private _removeElement( element: Element ): void { + if ( this._elements.has( element ) ) { + this.stopListening( element ); + this._elements.delete( element ); + } + if ( element === this.focusedElement ) { this._blur(); } + } + + /** + * Adds an external `FocusTracker` instance to this focus tracker and makes it contribute to this focus tracker's state. + */ + private _addView( view: ViewWithFocusTracker ): void { + if ( view.element ) { + this._addElement( view.element ); + } - if ( this._elements.has( element ) ) { - this.stopListening( element ); - this._elements.delete( element ); + this.listenTo( view.focusTracker, 'change:focusedElement', () => { + // @if CK_DEBUG_FOCUSTRACKER // console.log( + // @if CK_DEBUG_FOCUSTRACKER // `"${ getName( this ) }": Related "${ getName( view.focusTracker ) }"#focusedElement = `, + // @if CK_DEBUG_FOCUSTRACKER // view.focusTracker.focusedElement + // @if CK_DEBUG_FOCUSTRACKER // ); + + if ( view.focusTracker.focusedElement ) { + if ( view.element ) { + this._focus( view.element ); + } + } else { + this._blur(); + } + } ); + + this._externalViews.add( view ); + } + + /** + * Removes an external `FocusTracker` instance from this focus tracker. + */ + private _removeView( view: ViewWithFocusTracker ): void { + if ( view.element ) { + this._removeElement( view.element ); } + + this.stopListening( view.focusTracker ); + this._externalViews.delete( view ); } /** @@ -111,12 +242,20 @@ export default class FocusTracker extends /* #__PURE__ */ DomEmitterMixin( /* #_ */ public destroy(): void { this.stopListening(); + + this._elements.clear(); + this._externalViews.clear(); + + this.isFocused = false; + this.focusedElement = null; } /** - * Stores currently focused element and set {@link #isFocused} as `true`. + * Stores currently focused element as {@link #focusedElement} and sets {@link #isFocused} `true`. */ private _focus( element: Element ): void { + // @if CK_DEBUG_FOCUSTRACKER // console.log( `"${ getName( this ) }": _focus() on element`, element ); + clearTimeout( this._nextEventLoopTimeout! ); this.focusedElement = element; @@ -124,15 +263,123 @@ export default class FocusTracker extends /* #__PURE__ */ DomEmitterMixin( /* #_ } /** - * Clears currently focused element and set {@link #isFocused} as `false`. - * This method uses `setTimeout` to change order of fires `blur` and `focus` events. + * Clears currently {@link #focusedElement} and sets {@link #isFocused} `false`. + * + * This method uses `setTimeout()` to change order of fires `blur` and `focus` events ensuring that moving focus between + * two elements within a single focus tracker's scope, will not cause `[ blurA, focusB ]` sequence but just `[ focusB ]`. + * The former would cause a momentary change of `#isFocused` to `false` which is not desired because any logic listening to + * a focus tracker state would experience UI flashes and glitches as the user focus travels across the UI. */ private _blur(): void { + // Avoid blurs that would be incorrect as a result of #elements and #externalViews (and their focus trackers) coexisting: + // * External FT blurs (e.g. when the focus still remains in one of the #elements or another #externalView), + // * #elements blurs (e.g. when the focus still remains in one of #externalViews). + if ( + this.elements.find( element => element.contains( document.activeElement ) ) || + this.externalViews.find( view => view.focusTracker.isFocused ) + ) { + return; + } + clearTimeout( this._nextEventLoopTimeout! ); this._nextEventLoopTimeout = setTimeout( () => { + // @if CK_DEBUG_FOCUSTRACKER // console.log( `"${ getName( this ) }": Blur.` ); + this.focusedElement = null; this.isFocused = false; }, 0 ); } + + // @if CK_DEBUG_FOCUSTRACKER // public static _instances: Array = []; +} + +/** + * A {@link module:ui/view~View} instance with a {@link module:utils/focustracker~FocusTracker} instance exposed + * at the `#focusTracker` property. + */ +export type ViewWithFocusTracker = View & { focusTracker: FocusTracker }; + +/** + * Checks whether a view is an instance of {@link ~ViewWithFocusTracker}. + */ +export function isViewWithFocusTracker( view: any ): view is ViewWithFocusTracker { + return 'focusTracker' in view && view.focusTracker instanceof FocusTracker; } + +function isElement( value: any ): value is Element { + return _isElement( value ); +} + +function isExternalViewSubtreeFocused( subTreeRoot: Element, view: ViewWithFocusTracker ): boolean { + if ( isFocusedView( subTreeRoot, view ) ) { + return true; + } + + return !!view.focusTracker.externalViews.find( view => isFocusedView( subTreeRoot, view ) ); +} + +function isFocusedView( subTreeRoot: Element, view: View ): boolean { + // Note: You cannot depend on externalView.focusTracker.focusedElement because blurs are asynchronous and the value may + // be outdated when moving focus between two elements. Using document.activeElement instead. + return !!view.element && view.element.contains( document.activeElement ) && subTreeRoot.contains( view.element ); +} + +// @if CK_DEBUG_FOCUSTRACKER // declare global { +// @if CK_DEBUG_FOCUSTRACKER // interface Window { +// @if CK_DEBUG_FOCUSTRACKER // logFocusTrackers: Function; +// @if CK_DEBUG_FOCUSTRACKER // } +// @if CK_DEBUG_FOCUSTRACKER // } +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // function getName( focusTracker: FocusTracker ): string { +// @if CK_DEBUG_FOCUSTRACKER // return focusTracker._label || 'Unknown'; +// @if CK_DEBUG_FOCUSTRACKER // } +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // function logState( +// @if CK_DEBUG_FOCUSTRACKER // focusTracker: FocusTracker, +// @if CK_DEBUG_FOCUSTRACKER // keysToLog: Array = [ 'isFocused', 'focusedElement' ] +// @if CK_DEBUG_FOCUSTRACKER // ): string { +// @if CK_DEBUG_FOCUSTRACKER // keysToLog.forEach( key => { console.log( `${ key }=`, focusTracker[ key ] ) } ); +// @if CK_DEBUG_FOCUSTRACKER // } +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // window.logFocusTrackers = ( +// @if CK_DEBUG_FOCUSTRACKER // filter = () => true, +// @if CK_DEBUG_FOCUSTRACKER // keysToLog: Array +// @if CK_DEBUG_FOCUSTRACKER // ): void => { +// @if CK_DEBUG_FOCUSTRACKER // console.group( 'FocusTrackers' ); +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // for ( const focusTracker of FocusTracker._instances ) { +// @if CK_DEBUG_FOCUSTRACKER // if ( filter( focusTracker ) ) { +// @if CK_DEBUG_FOCUSTRACKER // console.group( `"${ getName( focusTracker ) }"` ); +// @if CK_DEBUG_FOCUSTRACKER // logState( focusTracker, keysToLog ); +// @if CK_DEBUG_FOCUSTRACKER // console.groupEnd(); +// @if CK_DEBUG_FOCUSTRACKER // } +// @if CK_DEBUG_FOCUSTRACKER // } +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // console.groupEnd(); +// @if CK_DEBUG_FOCUSTRACKER // }; +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // window.logFocusTrackerTree = ( +// @if CK_DEBUG_FOCUSTRACKER // rootFocusTracker: FocusTracker, +// @if CK_DEBUG_FOCUSTRACKER // filter = () => true, +// @if CK_DEBUG_FOCUSTRACKER // keysToLog: Array +// @if CK_DEBUG_FOCUSTRACKER // ): void => { +// @if CK_DEBUG_FOCUSTRACKER // console.group( 'FocusTrackers tree' ); +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // logBranch( rootFocusTracker, filter ); +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // function logBranch( focusTracker, filter ) { +// @if CK_DEBUG_FOCUSTRACKER // console.group( `"${ getName( focusTracker ) }"` ); +// @if CK_DEBUG_FOCUSTRACKER // logState( focusTracker, keysToLog ); +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // for ( const externalView of focusTracker.externalViews ) { +// @if CK_DEBUG_FOCUSTRACKER // if ( filter( externalView.focusTracker ) ) { +// @if CK_DEBUG_FOCUSTRACKER // logBranch( externalView.focusTracker, filter ); +// @if CK_DEBUG_FOCUSTRACKER // } +// @if CK_DEBUG_FOCUSTRACKER // } +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // console.groupEnd(); +// @if CK_DEBUG_FOCUSTRACKER // } +// @if CK_DEBUG_FOCUSTRACKER // +// @if CK_DEBUG_FOCUSTRACKER // console.groupEnd(); +// @if CK_DEBUG_FOCUSTRACKER // }; diff --git a/packages/ckeditor5-utils/src/index.ts b/packages/ckeditor5-utils/src/index.ts index 0cc02b52f64..296ea634ecb 100644 --- a/packages/ckeditor5-utils/src/index.ts +++ b/packages/ckeditor5-utils/src/index.ts @@ -80,7 +80,7 @@ export { type CollectionRemoveEvent } from './collection.js'; export { default as first } from './first.js'; -export { default as FocusTracker } from './focustracker.js'; +export { default as FocusTracker, type ViewWithFocusTracker, isViewWithFocusTracker } from './focustracker.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'; diff --git a/packages/ckeditor5-utils/tests/focustracker.js b/packages/ckeditor5-utils/tests/focustracker.js index 61301ca2fe0..16c5f253d09 100644 --- a/packages/ckeditor5-utils/tests/focustracker.js +++ b/packages/ckeditor5-utils/tests/focustracker.js @@ -9,9 +9,10 @@ import FocusTracker from '../src/focustracker.js'; import global from '../src/dom/global.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; import { expectToThrowCKEditorError } from './_utils/utils.js'; +import { View } from '@ckeditor/ckeditor5-ui'; describe( 'FocusTracker', () => { - let focusTracker, container, containerFirstInput, containerSecondInput; + let focusTracker, container, containerFirstInput, containerSecondInput, clock; testUtils.createSinonSandbox(); @@ -23,15 +24,21 @@ describe( 'FocusTracker', () => { container.appendChild( containerFirstInput ); container.appendChild( containerSecondInput ); - testUtils.sinon.useFakeTimers(); + clock = testUtils.sinon.useFakeTimers(); focusTracker = new FocusTracker(); + focusTracker._label = 'Root'; + } ); + + afterEach( () => { + clock.restore(); + focusTracker.destroy(); } ); describe( 'constructor()', () => { describe( 'isFocused', () => { it( 'should be false at default', () => { - expect( focusTracker.isFocused ).to.false; + expect( focusTracker.isFocused ).to.be.false; } ); it( 'should be observable', () => { @@ -41,7 +48,7 @@ describe( 'FocusTracker', () => { focusTracker.isFocused = true; - expect( observableSpy.calledOnce ).to.true; + expect( observableSpy.calledOnce ).to.be.true; } ); } ); @@ -57,153 +64,815 @@ describe( 'FocusTracker', () => { focusTracker.focusedElement = global.document.body; - expect( observableSpy.calledOnce ).to.true; + expect( observableSpy.calledOnce ).to.be.true; } ); } ); } ); describe( 'add()', () => { - it( 'should throw an error when element has been already added', () => { - focusTracker.add( containerFirstInput ); - - expectToThrowCKEditorError( () => { + describe( 'from DOM elements', () => { + it( 'should throw an error when element has been already added', () => { focusTracker.add( containerFirstInput ); - }, 'focustracker-add-element-already-exist', focusTracker ); - } ); - describe( 'single element', () => { - it( 'should start listening on element focus and update `isFocused` property', () => { - focusTracker.add( containerFirstInput ); + expectToThrowCKEditorError( () => { + focusTracker.add( containerFirstInput ); + }, 'focustracker-add-element-already-exist', focusTracker ); + } ); - expect( focusTracker.isFocused ).to.false; + describe( 'single element', () => { + it( 'should start listening on element focus and update `isFocused` property', () => { + focusTracker.add( containerFirstInput ); - containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + expect( focusTracker.isFocused ).to.be.false; - expect( focusTracker.isFocused ).to.true; - expect( focusTracker.focusedElement ).to.equal( containerFirstInput ); - } ); + containerFirstInput.dispatchEvent( new Event( 'focus' ) ); - it( 'should start listening on element blur and update `isFocused` property', () => { - focusTracker.add( containerFirstInput ); - containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( containerFirstInput ); + } ); - expect( focusTracker.focusedElement ).to.equal( containerFirstInput ); + it( 'should start listening on element blur and update `isFocused` property', () => { + focusTracker.add( containerFirstInput ); + containerFirstInput.dispatchEvent( new Event( 'focus' ) ); - containerFirstInput.dispatchEvent( new Event( 'blur' ) ); - testUtils.sinon.clock.tick( 0 ); + expect( focusTracker.focusedElement ).to.equal( containerFirstInput ); - expect( focusTracker.isFocused ).to.false; - expect( focusTracker.focusedElement ).to.be.null; + containerFirstInput.dispatchEvent( new Event( 'blur' ) ); + testUtils.sinon.clock.tick( 0 ); + + expect( focusTracker.isFocused ).to.be.false; + expect( focusTracker.focusedElement ).to.be.null; + } ); } ); - } ); - describe( 'container element', () => { - it( 'should start listening on element focus using event capturing and update `isFocused` property', () => { - focusTracker.add( container ); + describe( 'container element', () => { + it( 'should start listening on element focus using event capturing and update `isFocused` property', () => { + focusTracker.add( container ); - expect( focusTracker.isFocused ).to.false; + expect( focusTracker.isFocused ).to.be.false; - containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( container ); + } ); + + it( 'should start listening on element blur using event capturing and update `isFocused` property', () => { + focusTracker.add( container ); + containerFirstInput.dispatchEvent( new Event( 'focus' ) ); - expect( focusTracker.isFocused ).to.true; - expect( focusTracker.focusedElement ).to.equal( container ); + expect( focusTracker.focusedElement ).to.equal( container ); + + containerFirstInput.dispatchEvent( new Event( 'blur' ) ); + testUtils.sinon.clock.tick( 0 ); + + expect( focusTracker.isFocused ).to.be.false; + expect( focusTracker.focusedElement ).to.be.null; + } ); + + it( 'should not change `isFocused` property when focus is going between child elements', () => { + const changeSpy = testUtils.sinon.spy(); + + focusTracker.add( container ); + + containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + expect( focusTracker.focusedElement ).to.equal( container ); + expect( focusTracker.isFocused ).to.be.true; + + focusTracker.listenTo( focusTracker, 'change:isFocused', changeSpy ); + + containerFirstInput.dispatchEvent( new Event( 'blur' ) ); + containerSecondInput.dispatchEvent( new Event( 'focus' ) ); + testUtils.sinon.clock.tick( 0 ); + + expect( focusTracker.focusedElement ).to.equal( container ); + expect( focusTracker.isFocused ).to.be.true; + expect( changeSpy.notCalled ).to.be.true; + } ); + + // https://github.com/ckeditor/ckeditor5-utils/issues/159 + it( 'should keep `isFocused` synced when multiple blur events are followed by the focus', () => { + focusTracker.add( container ); + container.dispatchEvent( new Event( 'focus' ) ); + + expect( focusTracker.focusedElement ).to.equal( container ); + + container.dispatchEvent( new Event( 'blur' ) ); + containerFirstInput.dispatchEvent( new Event( 'blur' ) ); + containerSecondInput.dispatchEvent( new Event( 'focus' ) ); + testUtils.sinon.clock.tick( 0 ); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( container ); + } ); } ); - it( 'should start listening on element blur using event capturing and update `isFocused` property', () => { - focusTracker.add( container ); - containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + describe( 'for views', () => { + describe( 'without focus tracker', () => { + let view; - expect( focusTracker.focusedElement ).to.equal( container ); + beforeEach( () => { + view = new FocusableView(); - containerFirstInput.dispatchEvent( new Event( 'blur' ) ); - testUtils.sinon.clock.tick( 0 ); + view.render(); + document.body.appendChild( view.element ); + } ); - expect( focusTracker.isFocused ).to.false; - expect( focusTracker.focusedElement ).to.be.null; + afterEach( () => { + view.destroy(); + view.element.remove(); + } ); + + it( 'should add view#element as a plain DOM element', () => { + focusTracker.add( view ); + + view.focus(); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( view.element ); + } ); + + it( 'should not be listed in #externalViews', () => { + focusTracker.add( view ); + + expect( focusTracker.externalViews ).to.have.length( 0 ); + } ); + + it( 'should contribute to #elements', () => { + focusTracker.add( view ); + + expect( focusTracker.elements ).to.deep.equal( [ view.element ] ); + } ); + + it( 'should throw if view#element is unavailable', () => { + const view = new FocusableView(); + + view.element = null; + + expectToThrowCKEditorError( () => { + focusTracker.add( view ); + }, 'focustracker-add-view-missing-element', { + focusTracker, + view + } ); + } ); + } ); + + describe( 'with focus tracker', () => { + let childViewA, childViewB, childViewC, rootFocusTracker, rootElement, isFocusedSpy, focusedElementSpy; + + beforeEach( () => { + rootElement = document.createElement( 'root' ); + rootElement.setAttribute( 'tabindex', 0 ); + + document.body.appendChild( rootElement ); + + childViewA = new FocusableViewWithFocusTracker( 'child-a' ); + childViewB = new FocusableViewWithFocusTracker( 'child-b' ); + childViewC = new FocusableViewWithFocusTracker( 'child-c' ); + + childViewA.render(); + childViewB.render(); + childViewC.render(); + + rootFocusTracker = focusTracker; + + isFocusedSpy = sinon.spy(); + focusedElementSpy = sinon.spy(); + + rootFocusTracker.on( 'change:isFocused', ( evt, name, isFocused ) => isFocusedSpy( isFocused ) ); + rootFocusTracker.on( 'change:focusedElement', ( evt, name, focusedElement ) => { + focusedElementSpy( focusedElement ); + } ); + } ); + + afterEach( () => { + childViewA.destroy(); + childViewB.destroy(); + childViewC.destroy(); + rootElement.remove(); + + childViewA.element.remove(); + childViewB.element.remove(); + childViewC.element.remove(); + } ); + + it( 'should add view as a related view', () => { + rootElement.appendChild( childViewA.element ); + rootFocusTracker.add( childViewA ); + + childViewA.children.first.focus(); + + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + } ); + + it( 'should be listed in #externalViews', () => { + rootFocusTracker.add( childViewA ); + + expect( rootFocusTracker.externalViews ).to.deep.equal( [ childViewA ] ); + } ); + + it( 'should contribute to #elements', () => { + rootFocusTracker.add( childViewA ); + + expect( rootFocusTracker.elements ).to.deep.equal( [ childViewA.element ] ); + } ); + + describe( 'focus detection between linked focus trackers', () => { + it( 'should set #focusedElement to a child view#element when a sub-child got focused', () => { + // (tracked by root FT) + // (tracked by root FT) + // (tracked by child-a) -> 1st focus + // (tracked by child-a) + // + // + rootElement.appendChild( childViewA.element ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + + childViewA.children.get( 0 ).focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledOnce( focusedElementSpy ); + } ); + + it( 'should set #focusedElement to a child view#element when multiple sub-child got focused in short order', () => { + // (tracked by root FT) + // (tracked by root FT) + // (tracked by child-a) -> 1st focus + // (tracked by child-a) -> 2nd focus + // + // + rootElement.appendChild( childViewA.element ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + + childViewA.children.get( 0 ).focus(); + childViewA.children.get( 1 ).focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledOnce( focusedElementSpy ); + } ); + + it( 'should set #focusedElement the correct child view#element if two successive focuses ocurred in that view ' + + 'at different nesting levels', + () => { + // (tracked by root FT) + // (tracked by root FT) + // (tracked by child-a) + // (tracked by child-a) -> 1st focus + // (tracked by child-b) -> 2nd focus + // (tracked by child-b) + // + // + // (tracked by child-a) + // + // + childViewA.children.get( 0 ).children.add( childViewB ); + rootElement.appendChild( childViewA.element ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + childViewA.focusTracker.add( childViewB ); + + childViewB.focus(); + childViewB.children.get( 0 ).focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledOnce( focusedElementSpy ); + } ); + + it( 'should set #focusedElement to a child view#element when a logically connected sub-child was focused', () => { + // (tracked by root FT) + // (tracked by root FT) + // (tracked by child-a) + // (tracked by child-a) + // + // (tracked by child-a) + // (tracked by child-b) -> 1st focus + // (tracked by child-b) + // + // + rootElement.appendChild( childViewA.element ); + rootElement.appendChild( childViewB.element ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + childViewA.focusTracker.add( childViewB ); + + childViewB.children.get( 0 ).focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledOnce( focusedElementSpy ); + } ); + + it( 'should set #focusedElement to a child view#element when multiple logically connected sub-children were ' + + 'focused in short order', + () => { + // (tracked by root FT) + // (tracked by root FT) + // (tracked by child-a) + // (tracked by child-a) + // + // (tracked by child-a) + // (tracked by child-b) -> 1st focus + // (tracked by child-b) + // + // (tracked by child-a) + // (tracked by child-c) -> 2nd focus + // (tracked by child-c) + // + // + rootElement.appendChild( childViewA.element ); + rootElement.appendChild( childViewB.element ); + rootElement.appendChild( childViewC.element ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + childViewA.focusTracker.add( childViewB ); + childViewA.focusTracker.add( childViewC ); + + childViewB.children.get( 0 ).focus(); + childViewC.children.get( 0 ).focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledOnce( focusedElementSpy ); + } ); + } ); + + describe( 'blur handling between linked focus trackers', () => { + it( 'should set #focusedElement to the root element if focus moved back from the child view', () => { + // (tracked by root FT) -> 2nd focus + // (tracked by root FT) + // (tracked by child-a) -> 1st focus + // (tracked by child-a) + // + // + rootElement.appendChild( childViewA.element ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + + childViewA.children.get( 0 ).focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + rootElement.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( rootElement ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledTwice( focusedElementSpy ); + sinon.assert.calledWithExactly( focusedElementSpy.firstCall, childViewA.element ); + sinon.assert.calledWithExactly( focusedElementSpy.secondCall, rootElement ); + } ); + + it( 'should set #focusedElement to the view#element if focus moved from the sub-child to the child view', () => { + // (tracked by root FT) + // (tracked by root FT) -> 2nd focus + // (tracked by child-a) -> 1st focus + // (tracked by child-a) + // + // + rootElement.appendChild( childViewA.element ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + + childViewA.children.get( 0 ).focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + childViewA.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledOnce( focusedElementSpy ); + } ); + + it( 'should set #focusedElement to a view#element in a different DOM sub-tree when its child gets focused', () => { + // (tracked by root FT) + // (tracked by root FT) -> 1st focus + // (tracked by child-a) + // (tracked by child-a) + // + // + // (tracked by root FT) + // (tracked by child-b) -> 2nd focus + // (tracked by child-b) + // + rootElement.appendChild( childViewA.element ); + document.body.appendChild( childViewB.element ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + rootFocusTracker.add( childViewB ); + + childViewA.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + childViewB.children.first.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewB.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledTwice( focusedElementSpy ); + sinon.assert.calledWithExactly( focusedElementSpy.firstCall, childViewA.element ); + sinon.assert.calledWithExactly( focusedElementSpy.secondCall, childViewB.element ); + } ); + + it( 'should set #focusedElement to a view#element in a different DOM sub-tree when it gets focused', () => { + // (tracked by root FT) + // (tracked by root FT) -> 1st focus + // (tracked by child-a) + // (tracked by child-a) + // + // + // (tracked by root FT) -> 2nd focus + // (tracked by child-b) + // (tracked by child-b) + // + rootElement.appendChild( childViewA.element ); + document.body.appendChild( childViewB.element ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + rootFocusTracker.add( childViewB ); + + childViewA.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + childViewB.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewB.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledTwice( focusedElementSpy ); + sinon.assert.calledWithExactly( focusedElementSpy.firstCall, childViewA.element ); + sinon.assert.calledWithExactly( focusedElementSpy.secondCall, childViewB.element ); + } ); + + it( 'should preserve #focusedElement if a focused element in a sub-tree was removed', () => { + // (tracked by root FT) + // (tracked by root FT) -> 1st focus, then remove + // (tracked by child-a) + // (tracked by child-a) + // + // + rootElement.appendChild( childViewA.element ); + rootFocusTracker.add( childViewA ); + + childViewA.children.first.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledOnce( focusedElementSpy ); + + childViewA.element.remove(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledOnce( focusedElementSpy ); + } ); + + it( 'should avoid accidental blurs as the focus traverses multiple DOM sub-trees', async () => { + // (tracked by root FT) + // (tracked by root FT) + // (tracked by child-a) -> 1st focus + // (tracked by child-a) + // + // + // (tracked by root FT) -> 2nd focus + // (tracked by child-b) + // (tracked by child-b) + // (tracked by child-c) + // (tracked by child-c) -> 3rd focus + // + // + // (tracked by child-b) -> 4th focus + // + rootElement.appendChild( childViewA.element ); + document.body.appendChild( childViewB.element ); + childViewB.children.first.children.add( childViewC ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + rootFocusTracker.add( childViewB ); + childViewB.focusTracker.add( childViewC ); + + childViewA.children.first.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + childViewB.focus(); + childViewC.children.last.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewB.element ); + + childViewB.children.last.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewB.element ); + + sinon.assert.calledOnce( isFocusedSpy ); + sinon.assert.calledTwice( focusedElementSpy ); + sinon.assert.calledWithExactly( focusedElementSpy.firstCall, childViewA.element ); + sinon.assert.calledWithExactly( focusedElementSpy.secondCall, childViewB.element ); + } ); + } ); + } ); } ); + } ); + } ); - it( 'should not change `isFocused` property when focus is going between child elements', () => { - const changeSpy = testUtils.sinon.spy(); + describe( 'remove()', () => { + describe( 'for DOM elements', () => { + it( 'should do nothing when element was not added', () => { + expect( () => { + focusTracker.remove( container ); + } ).to.not.throw(); + } ); - focusTracker.add( container ); + it( 'should stop listening on element focus', () => { + focusTracker.add( containerFirstInput ); + focusTracker.remove( containerFirstInput ); containerFirstInput.dispatchEvent( new Event( 'focus' ) ); - expect( focusTracker.focusedElement ).to.equal( container ); - expect( focusTracker.isFocused ).to.true; - focusTracker.listenTo( focusTracker, 'change:isFocused', changeSpy ); + expect( focusTracker.isFocused ).to.be.false; + expect( focusTracker.focusedElement ).to.be.null; + } ); + + it( 'should stop listening on element blur', () => { + focusTracker.add( containerFirstInput ); + focusTracker.remove( containerFirstInput ); + focusTracker.isFocused = true; containerFirstInput.dispatchEvent( new Event( 'blur' ) ); - containerSecondInput.dispatchEvent( new Event( 'focus' ) ); testUtils.sinon.clock.tick( 0 ); - expect( focusTracker.focusedElement ).to.equal( container ); - expect( focusTracker.isFocused ).to.true; - expect( changeSpy.notCalled ).to.true; + expect( focusTracker.isFocused ).to.be.true; } ); - // https://github.com/ckeditor/ckeditor5-utils/issues/159 - it( 'should keep `isFocused` synced when multiple blur events are followed by the focus', () => { - focusTracker.add( container ); - container.dispatchEvent( new Event( 'focus' ) ); + it( 'should blur element before removing when is focused', () => { + focusTracker.add( containerFirstInput ); + containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + expect( focusTracker.focusedElement ).to.equal( containerFirstInput ); - expect( focusTracker.focusedElement ).to.equal( container ); + expect( focusTracker.isFocused ).to.be.true; - container.dispatchEvent( new Event( 'blur' ) ); - containerFirstInput.dispatchEvent( new Event( 'blur' ) ); - containerSecondInput.dispatchEvent( new Event( 'focus' ) ); + focusTracker.remove( containerFirstInput ); testUtils.sinon.clock.tick( 0 ); - expect( focusTracker.isFocused ).to.be.true; - expect( focusTracker.focusedElement ).to.equal( container ); + expect( focusTracker.isFocused ).to.be.false; + expect( focusTracker.focusedElement ).to.be.null; } ); } ); - } ); - describe( 'remove()', () => { - it( 'should do nothing when element was not added', () => { - expect( () => { - focusTracker.remove( container ); - } ).to.not.throw(); - } ); + describe( 'for views', () => { + describe( 'without focus tracker', () => { + let viewA, viewB; - it( 'should stop listening on element focus', () => { - focusTracker.add( containerFirstInput ); - focusTracker.remove( containerFirstInput ); + beforeEach( () => { + viewA = new FocusableView( 'a' ); + viewB = new FocusableView( 'b' ); - containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + viewA.render(); + viewB.render(); - expect( focusTracker.isFocused ).to.false; - expect( focusTracker.focusedElement ).to.be.null; - } ); + document.body.appendChild( viewA.element ); + document.body.appendChild( viewB.element ); + } ); - it( 'should stop listening on element blur', () => { - focusTracker.add( containerFirstInput ); - focusTracker.remove( containerFirstInput ); - focusTracker.isFocused = true; + afterEach( () => { + viewA.destroy(); + viewB.destroy(); - containerFirstInput.dispatchEvent( new Event( 'blur' ) ); - testUtils.sinon.clock.tick( 0 ); + viewA.element.remove(); + viewB.element.remove(); + } ); - expect( focusTracker.isFocused ).to.true; - } ); + it( 'should stop listening to view#element just like any other DOM element', () => { + focusTracker.add( viewA ); + focusTracker.add( viewB ); + + viewA.focus(); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( viewA.element ); + + viewB.focus(); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( viewB.element ); - it( 'should blur element before removing when is focused', () => { - focusTracker.add( containerFirstInput ); - containerFirstInput.dispatchEvent( new Event( 'focus' ) ); - expect( focusTracker.focusedElement ).to.equal( containerFirstInput ); + focusTracker.remove( viewA ); + testUtils.sinon.clock.tick( 10 ); - expect( focusTracker.isFocused ).to.true; + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( viewB.element ); - focusTracker.remove( containerFirstInput ); - testUtils.sinon.clock.tick( 0 ); + viewA.focus(); - expect( focusTracker.isFocused ).to.false; - expect( focusTracker.focusedElement ).to.be.null; + expect( focusTracker.isFocused ).to.be.false; + expect( focusTracker.focusedElement ).to.equal( null ); + } ); + + it( 'should remove view#element from #elements', () => { + focusTracker.add( viewA ); + focusTracker.add( viewB ); + + expect( focusTracker.elements ).to.have.ordered.members( [ viewA.element, viewB.element ] ); + + focusTracker.remove( viewA ); + testUtils.sinon.clock.tick( 10 ); + + expect( focusTracker.elements ).to.deep.equal( [ viewB.element ] ); + } ); + + it( 'should update state upon removing view#element if focused', () => { + focusTracker.add( viewA ); + + viewA.focus(); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( viewA.element ); + + focusTracker.remove( viewA ); + testUtils.sinon.clock.tick( 10 ); + + expect( focusTracker.isFocused ).to.be.false; + expect( focusTracker.focusedElement ).to.equal( null ); + } ); + } ); + + describe( 'with focus tracker', () => { + let childViewA, childViewB, rootFocusTracker, rootElement; + + beforeEach( () => { + rootElement = document.createElement( 'root' ); + rootElement.setAttribute( 'tabindex', 0 ); + + document.body.appendChild( rootElement ); + + childViewA = new FocusableViewWithFocusTracker( 'child-a' ); + childViewB = new FocusableViewWithFocusTracker( 'child-b' ); + + childViewA.render(); + childViewB.render(); + + rootFocusTracker = focusTracker; + } ); + + afterEach( () => { + childViewA.destroy(); + childViewB.destroy(); + rootElement.remove(); + childViewA.element.remove(); + childViewB.element.remove(); + } ); + + it( 'should stop listening to view#element just like any other DOM element', () => { + focusTracker.add( childViewA ); + focusTracker.add( childViewB ); + + document.body.appendChild( childViewA.element ); + document.body.appendChild( childViewB.element ); + + childViewA.focus(); + + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + childViewB.focus(); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( childViewB.element ); + + focusTracker.remove( childViewA ); + testUtils.sinon.clock.tick( 10 ); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( childViewB.element ); + + childViewA.focus(); + + expect( focusTracker.isFocused ).to.be.false; + expect( focusTracker.focusedElement ).to.be.null; + } ); + + it( 'should remove view from #externalViews', () => { + focusTracker.add( childViewA ); + + expect( focusTracker.externalViews ).to.deep.equal( [ childViewA ] ); + + focusTracker.remove( childViewA ); + + expect( focusTracker.externalViews ).to.deep.equal( [] ); + } ); + + it( 'should remove view#element from #elements', () => { + focusTracker.add( childViewA ); + + expect( focusTracker.elements ).to.deep.equal( [ childViewA.element ] ); + + focusTracker.remove( childViewA ); + + expect( focusTracker.elements ).to.deep.equal( [] ); + } ); + + it( 'should update the focus tracker\'s state upon removing view#element (if view#element was focused)', () => { + focusTracker.add( childViewA ); + focusTracker.add( childViewB ); + + document.body.appendChild( childViewA.element ); + document.body.appendChild( childViewB.element ); + + childViewA.focus(); + + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + focusTracker.remove( childViewA ); + testUtils.sinon.clock.tick( 10 ); + + expect( focusTracker.isFocused ).to.be.false; + expect( focusTracker.focusedElement ).to.be.null; + } ); + + it( 'should not blur the focus tracker if another focus tracker is focused', () => { + focusTracker.add( childViewA ); + focusTracker.add( childViewB ); + + document.body.appendChild( childViewA.element ); + document.body.appendChild( childViewB.element ); + + childViewA.focus(); + + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + childViewB.focus(); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( childViewB.element ); + + focusTracker.remove( childViewA ); + testUtils.sinon.clock.tick( 10 ); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( childViewB.element ); + } ); + + it( 'should not blur the focus tracker if another DOM element is focused', () => { + const focusableDomElement = document.createElement( 'button' ); + + focusTracker.add( childViewA ); + focusTracker.add( focusableDomElement ); + + document.body.appendChild( childViewA.element ); + document.body.appendChild( focusableDomElement ); + + childViewA.focus(); + + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + focusableDomElement.focus(); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( focusableDomElement ); + + focusTracker.remove( childViewA ); + testUtils.sinon.clock.tick( 10 ); + + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( focusableDomElement ); + + focusableDomElement.remove(); + } ); + } ); } ); } ); - describe( 'elements', () => { + describe( '#elements', () => { it( 'should return an array with elements currently added to the focus tracker', () => { expect( focusTracker.elements ).to.deep.equal( [] ); @@ -238,6 +907,19 @@ describe( 'FocusTracker', () => { } ); } ); + describe( '#externalViews', () => { + it( 'should return an array of views linked to the focus tracker that contribute to its state', () => { + const viewA = new FocusableViewWithFocusTracker( 'a' ); + const viewB = new FocusableViewWithFocusTracker( 'a' ); + + focusTracker.add( viewA ); + focusTracker.add( viewB ); + + expect( focusTracker.externalViews ).to.be.instanceOf( Array ); + expect( focusTracker.externalViews ).to.have.ordered.members( [ viewA, viewB ] ); + } ); + } ); + describe( 'destroy()', () => { it( 'should stop listening', () => { const stopListeningSpy = sinon.spy( focusTracker, 'stopListening' ); @@ -247,4 +929,52 @@ describe( 'FocusTracker', () => { sinon.assert.calledOnce( stopListeningSpy ); } ); } ); + + class FocusableView extends View { + constructor( elementName = 'childElement' ) { + super(); + + this.children = this.createCollection(); + + this.setTemplate( { + tag: elementName, + attributes: { + tabindex: 0 + }, + children: this.children + } ); + } + + focus() { + this.element.focus(); + sinon.clock.tick( 10 ); + } + } + + class FocusableViewWithFocusTracker extends FocusableView { + constructor( elementName ) { + super( elementName ); + + this.children.addMany( [ + new FocusableView( elementName + '-a' ), + new FocusableView( elementName + '-b' ) + ] ); + + this.focusTracker = new FocusTracker(); + this.focusTracker._label = elementName; + } + + render() { + super.render(); + + this.focusTracker.add( this.children.get( 0 ) ); + this.focusTracker.add( this.children.get( 1 ) ); + } + + destroy() { + super.destroy(); + + this.focusTracker.destroy(); + } + } } );