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/menu/dropdownmenunestedmenuview.ts b/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenunestedmenuview.ts index 701d04c18a1..e5ee1af6e20 100644 --- a/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenunestedmenuview.ts +++ b/packages/ckeditor5-ui/src/dropdown/menu/dropdownmenunestedmenuview.ts @@ -209,6 +209,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/utils.ts b/packages/ckeditor5-ui/src/dropdown/utils.ts index 02918783076..006f2da9f76 100644 --- a/packages/ckeditor5-ui/src/dropdown/utils.ts +++ b/packages/ckeditor5-ui/src/dropdown/utils.ts @@ -34,6 +34,7 @@ import { global, priorities, logWarning, + type FocusTracker, type Collection, type Locale, type ObservableChangeEvent @@ -198,6 +199,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 ); @@ -231,7 +233,7 @@ function addMenuToOpenDropdown( // Nested menu panels are added to body collection, so they are not children of the `dropdownView` from DOM perspective. // Add these panels to `dropdownView` focus tracker, so they are treated like part of the `dropdownView` for focus-related purposes. for ( const menu of dropdownMenuRootListView.menus ) { - dropdownView.focusTracker.add( menu.panelView.element! ); + dropdownView.focusTracker.add( menu ); } dropdownMenuRootListView.ariaLabel = options.ariaLabel || t( 'Dropdown menu' ); @@ -360,6 +362,7 @@ function addToolbarToOpenDropdown( } dropdownView.panelView.children.add( toolbarView ); + dropdownView.focusTracker.add( toolbarView ); toolbarView.items.delegate( 'execute' ).to( dropdownView ); } @@ -537,11 +540,25 @@ function closeDropdownOnClickOutside( dropdownView: DropdownView ) { }, contextElements: () => [ dropdownView.element!, - ...dropdownView.focusTracker.elements + // Include all elements connected to the dropdown's focus tracker, but exclude those that are direct children + // of DropdownView#element. They would be identified as descendants of #element anyway upon clicking and would + // not contribute to the logic. + ...getFocusTrackerTreeElements( dropdownView.focusTracker ).filter( element => !dropdownView.element!.contains( element ) ) ] } ); } +/** + * Returns all DOM elements connected to a DropdownView's focus tracker, either directly (same DOM sub-tree) + * or indirectly (external views registered in the focus tracker). + */ +function getFocusTrackerTreeElements( focusTracker: FocusTracker ): Array { + return [ + ...focusTracker.elements, + ...focusTracker.externalViews.flatMap( view => getFocusTrackerTreeElements( view.focusTracker ) ) + ]; +} + /** * Adds a behavior to a dropdownView that closes the dropdown view on "execute" event. */ diff --git a/packages/ckeditor5-ui/src/editorui/editorui.ts b/packages/ckeditor5-ui/src/editorui/editorui.ts index 21851581a36..ed5a890eef1 100644 --- a/packages/ckeditor5-ui/src/editorui/editorui.ts +++ b/packages/ckeditor5-ui/src/editorui/editorui.ts @@ -306,11 +306,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/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 ffa86edc555..e9f6c60984f 100644 --- a/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts +++ b/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts @@ -126,7 +126,7 @@ export default class BalloonToolbar extends Plugin { // 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..e07e2ac9a63 100644 --- a/packages/ckeditor5-ui/src/toolbar/toolbarview.ts +++ b/packages/ckeditor5-ui/src/toolbar/toolbarview.ts @@ -260,15 +260,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..50df003a877 100644 --- a/packages/ckeditor5-ui/tests/dropdown/utils.js +++ b/packages/ckeditor5-ui/tests/dropdown/utils.js @@ -6,7 +6,7 @@ /* globals document, Event, console */ import { assertBinding } from '@ckeditor/ckeditor5-utils/tests/_utils/utils.js'; -import { global, keyCodes, Locale } from '@ckeditor/ckeditor5-utils'; +import { FocusTracker, global, keyCodes, Locale } from '@ckeditor/ckeditor5-utils'; import Collection from '@ckeditor/ckeditor5-utils/src/collection.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; @@ -193,6 +193,42 @@ describe( 'utils', () => { documentElement.remove(); } ); + + it( 'considers DOM elements in different DOM sub-trees but connected via focus trackers', () => { + // + // + // + // + // + const childView = new View(); + const secondaryChildView = new View(); + + childView.setTemplate( { tag: 'child-view' } ); + secondaryChildView.setTemplate( { tag: 'secondary-child-view' } ); + + childView.focusTracker = new FocusTracker(); + + childView.render(); + secondaryChildView.render(); + document.body.appendChild( childView.element ); + document.body.appendChild( secondaryChildView.element ); + + // DropdownView#focusTracker -> child-view#focusTracker -> secondary-child-view#focusTracker + dropdownView.focusTracker.add( childView ); + childView.focusTracker.add( secondaryChildView ); + + dropdownView.isOpen = true; + + secondaryChildView.element.dispatchEvent( new Event( 'mousedown', { + bubbles: true + } ) ); + + // External view's element is logically connected to the dropdown view's element via focus tracker. + expect( dropdownView.isOpen ).to.be.true; + + childView.element.remove(); + secondaryChildView.element.remove(); + } ); } ); describe( 'closeDropdownOnExecute()', () => { @@ -690,6 +726,15 @@ describe( 'utils', () => { dropdownView.element.remove(); } ); + // https://github.com/cksource/ckeditor5-commercial/issues/6633 + it( 'should add the ToolbarView instance of dropdown\'s focus tracker to allow for using toolbar items distributed ' + + 'across the DOM sub-trees', () => { + // Lazy load. + dropdownView.isOpen = true; + + expect( dropdownView.focusTracker.externalViews ).to.include( dropdownView.toolbarView ); + } ); + it( 'focuses active item upon dropdown opening', () => { buttons[ 0 ].isOn = true; @@ -1391,6 +1436,22 @@ describe( 'utils', () => { expect( dropdownView.menuView.render.calledOnce ).to.be.true; } ); + // https://github.com/cksource/ckeditor5-commercial/issues/6633 + 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 ); + + dropdownView.isOpen = true; + + sinon.assert.calledThrice( addSpy ); + sinon.assert.calledWithExactly( addSpy.firstCall, dropdownView.menuView ); + sinon.assert.calledWithExactly( addSpy.secondCall, dropdownView.menuView.menus[ 0 ] ); + sinon.assert.calledWithExactly( addSpy.thirdCall, dropdownView.menuView.menus[ 1 ] ); + } ); + 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 3510da7e4c7..253966c39a5 100644 --- a/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js +++ b/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js @@ -247,6 +247,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; @@ -836,7 +843,7 @@ describe( 'BalloonToolbar', () => { } ); describe( 'MultiRoot editor integration', () => { - let rootsElements, addEditableOnRootAdd; + let rootsElements, addEditableOnRootAdd, focusHolder; beforeEach( async () => { addEditableOnRootAdd = true; @@ -857,6 +864,9 @@ describe( 'BalloonToolbar', () => { editor = await createMultiRootEditor(); balloonToolbar = editor.plugins.get( BalloonToolbar ); + + focusHolder = document.createElement( 'input' ); + document.body.appendChild( focusHolder ); } ); afterEach( async () => { @@ -866,6 +876,8 @@ describe( 'BalloonToolbar', () => { await editor.destroy(); editor = null; + + focusHolder.remove(); } ); it( 'should create plugin instance', () => { @@ -885,11 +897,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; } @@ -901,24 +913,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; @@ -930,21 +942,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; @@ -958,29 +970,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..7b049e29d2a 100644 --- a/packages/ckeditor5-utils/src/focustracker.ts +++ b/packages/ckeditor5-utils/src/focustracker.ts @@ -12,9 +12,12 @@ 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 or {@link module:ui/view~View view instances} 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 +30,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 #externalViews} is focused. * * @readonly * @observable @@ -37,45 +40,121 @@ 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 {@link module:ui/view~View#element elements} + * registered in {@link #externalViews}. + * * @readonly * @observable */ declare public focusedElement: Element | null; /** - * List of registered elements. + * List of registered DOM elements. * * @internal */ public _elements: Set = new Set(); /** - * Event loop timeout. + * List of views with external focus trackers that contribute to the state of this focus tracker. + * + * @internal + */ + public _externalViews: Set = new Set(); + + /** + * Asynchronous blur event timeout. */ - private _nextEventLoopTimeout: ReturnType | null = null; + private _blurTimeout: 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 #externalViews}. */ 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 #externalViews}. + * * 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,54 +164,242 @@ 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(); } + } - if ( this._elements.has( element ) ) { - this.stopListening( element ); - this._elements.delete( element ); + /** + * Adds an external {@link module:ui/view~View view instance} to this focus tracker and makes it contribute to this focus tracker's + * state either by its `View#element` or by its `View#focusTracker` instance. + */ + private _addView( view: ViewWithFocusTracker ): void { + if ( view.element ) { + this._addElement( view.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 {@link module:ui/view~View view 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 ); } /** * Destroys the focus tracker by: - * - Disabling all event listeners attached to tracked elements. - * - Removing all tracked elements that were previously added. + * - Disabling all event listeners attached to tracked elements or external views. + * - Removing all tracked elements and views that were previously added. */ 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 { - clearTimeout( this._nextEventLoopTimeout! ); + // @if CK_DEBUG_FOCUSTRACKER // console.log( `"${ getName( this ) }": _focus() on element`, element ); + + this._clearBlurTimeout(); this.focusedElement = element; this.isFocused = true; } /** - * 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 `blur` and `focus` events calls, 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 { - clearTimeout( this._nextEventLoopTimeout! ); + const isAnyElementFocused = this.elements.find( element => element.contains( document.activeElement ) ); + + // Avoid blurs originating from external FTs when the focus still remains in one of the #elements. + if ( isAnyElementFocused ) { + return; + } + + const isAnyExternalViewFocused = this.externalViews.find( view => { + // Do not consider external views's focus trackers as focused if there's a blur timeout pending. + return view.focusTracker.isFocused && !view.focusTracker._blurTimeout; + } ); + + // Avoid unnecessary DOM blurs coming from #elements when the focus still remains in one of #externalViews. + if ( isAnyExternalViewFocused ) { + return; + } + + this._clearBlurTimeout(); + + this._blurTimeout = setTimeout( () => { + // @if CK_DEBUG_FOCUSTRACKER // console.log( `"${ getName( this ) }": Blur.` ); - this._nextEventLoopTimeout = setTimeout( () => { this.focusedElement = null; this.isFocused = false; }, 0 ); } + + /** + * Clears the asynchronous blur event timeout on demand. See {@link #_blur} to learn more. + */ + private _clearBlurTimeout(): void { + clearTimeout( this._blurTimeout! ); + this._blurTimeout = null; + } + + // @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 // console.log( 'elements', focusTracker.elements ); +// @if CK_DEBUG_FOCUSTRACKER // console.log( 'externalViews', focusTracker.externalViews ); +// @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..5c9e12f4ea3 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,20 @@ describe( 'FocusTracker', () => { container.appendChild( containerFirstInput ); container.appendChild( containerSecondInput ); - testUtils.sinon.useFakeTimers(); + clock = testUtils.sinon.useFakeTimers(); focusTracker = new FocusTracker(); } ); + 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 +47,7 @@ describe( 'FocusTracker', () => { focusTracker.isFocused = true; - expect( observableSpy.calledOnce ).to.true; + expect( observableSpy.calledOnce ).to.be.true; } ); } ); @@ -57,153 +63,894 @@ 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( 'for 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' ) ); + + expect( focusTracker.isFocused ).to.be.true; + 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' ) ); + + expect( focusTracker.focusedElement ).to.equal( containerFirstInput ); + + containerFirstInput.dispatchEvent( new Event( 'blur' ) ); + testUtils.sinon.clock.tick( 0 ); + + expect( focusTracker.isFocused ).to.be.false; + expect( focusTracker.focusedElement ).to.be.null; + } ); } ); - it( 'should start listening on element blur and update `isFocused` property', () => { - focusTracker.add( containerFirstInput ); - containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + describe( 'container element', () => { + it( 'should start listening on element focus using event capturing and update `isFocused` property', () => { + focusTracker.add( container ); - expect( focusTracker.focusedElement ).to.equal( containerFirstInput ); + expect( focusTracker.isFocused ).to.be.false; - containerFirstInput.dispatchEvent( new Event( 'blur' ) ); - testUtils.sinon.clock.tick( 0 ); + containerFirstInput.dispatchEvent( new Event( 'focus' ) ); - expect( focusTracker.isFocused ).to.false; - expect( focusTracker.focusedElement ).to.be.null; + 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.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 ); + } ); } ); } ); - describe( 'container element', () => { - it( 'should start listening on element focus using event capturing and update `isFocused` property', () => { - focusTracker.add( container ); + describe( 'for views', () => { + describe( 'without focus tracker', () => { + let view; - expect( focusTracker.isFocused ).to.false; + beforeEach( () => { + view = new FocusableView(); - containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + view.render(); + document.body.appendChild( view.element ); + } ); - expect( focusTracker.isFocused ).to.true; - expect( focusTracker.focusedElement ).to.equal( container ); - } ); + afterEach( () => { + view.destroy(); + view.element.remove(); + } ); - it( 'should start listening on element blur using event capturing and update `isFocused` property', () => { - focusTracker.add( container ); - containerFirstInput.dispatchEvent( new Event( 'focus' ) ); + it( 'should add view#element as a plain DOM element', () => { + focusTracker.add( view ); - expect( focusTracker.focusedElement ).to.equal( container ); + view.focus(); - containerFirstInput.dispatchEvent( new Event( 'blur' ) ); - testUtils.sinon.clock.tick( 0 ); + expect( focusTracker.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( view.element ); + } ); - expect( focusTracker.isFocused ).to.false; - expect( focusTracker.focusedElement ).to.be.null; + 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 + } ); + } ); } ); - it( 'should not change `isFocused` property when focus is going between child elements', () => { - const changeSpy = testUtils.sinon.spy(); + describe( 'with focus tracker', () => { + let childViewA, childViewB, childViewC, rootFocusTracker, rootElement, isFocusedSpy, focusedElementSpy; - focusTracker.add( container ); + 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 (1)', () => { + // (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 avoid accidental blurs as the focus traverses multiple DOM sub-trees (2)', () => { + // (tracked by root FT) + // (tracked by root FT) + // (tracked by child-a) -> 1st focus + // (tracked by child-a) + // + // (tracked by root FT) -> 3rd focus + // (tracked by child-b) + // (tracked by child-b) + // + // + // (tracked by child-b) + // (tracked by child-c) + // (tracked by child-c) -> 2nd focus + // + rootElement.appendChild( childViewA.element ); + rootElement.appendChild( childViewB.element ); + document.body.appendChild( childViewC.element ); + + 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 ); + + childViewC.children.first.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewB.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 avoid accidental blurs as the focus traverses multiple DOM sub-trees (3)', () => { + // (tracked by root FT) + // (tracked by root FT) + // (tracked by child-a) -> 1st focus + // (tracked by child-a) + // + // (tracked by root FT) + // (tracked by child-b) + // (tracked by child-b) + // + // + // + // (tracked by child-c) -> 2nd focus + // (tracked by child-c) + // + rootElement.appendChild( childViewA.element ); + rootElement.appendChild( childViewB.element ); + document.body.appendChild( childViewC.element ); + + rootFocusTracker.add( rootElement ); + rootFocusTracker.add( childViewA ); + rootFocusTracker.add( childViewB ); + + childViewA.children.first.focus(); + expect( rootFocusTracker.isFocused ).to.be.true; + expect( rootFocusTracker.focusedElement ).to.equal( childViewA.element ); + + childViewC.children.first.focus(); + expect( rootFocusTracker.isFocused ).to.be.false; + expect( rootFocusTracker.focusedElement ).to.equal( null ); + + sinon.assert.calledTwice( isFocusedSpy ); + sinon.assert.calledTwice( focusedElementSpy ); + sinon.assert.calledWithExactly( focusedElementSpy.firstCall, childViewA.element ); + sinon.assert.calledWithExactly( focusedElementSpy.secondCall, null ); + } ); + } ); + } ); + } ); + + describe( 'remove()', () => { + describe( 'for DOM elements', () => { + it( 'should do nothing when element was not added', () => { + expect( () => { + focusTracker.remove( container ); + } ).to.not.throw(); + } ); + + 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 ); + + focusTracker.remove( viewA ); + testUtils.sinon.clock.tick( 10 ); - 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.isFocused ).to.be.true; + expect( focusTracker.focusedElement ).to.equal( viewB.element ); - expect( focusTracker.isFocused ).to.true; + viewA.focus(); - focusTracker.remove( containerFirstInput ); - testUtils.sinon.clock.tick( 0 ); + expect( focusTracker.isFocused ).to.be.false; + expect( focusTracker.focusedElement ).to.equal( null ); + } ); - expect( focusTracker.isFocused ).to.false; - expect( focusTracker.focusedElement ).to.be.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 +985,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 +1007,51 @@ 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(); + } + + render() { + super.render(); + + this.focusTracker.add( this.children.get( 0 ) ); + this.focusTracker.add( this.children.get( 1 ) ); + } + + destroy() { + super.destroy(); + + this.focusTracker.destroy(); + } + } } );