diff --git a/core/focus_manager.ts b/core/focus_manager.ts index c0139aec0..198e1f074 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -63,12 +63,16 @@ export class FocusManager { private currentlyHoldsEphemeralFocus: boolean = false; private lockFocusStateChanges: boolean = false; private recentlyLostAllFocus: boolean = false; + private isUpdatingFocusedNode: boolean = false; constructor( addGlobalEventListener: (type: string, listener: EventListener) => void, ) { // Note that 'element' here is the element *gaining* focus. const maybeFocus = (element: Element | EventTarget | null) => { + // Skip processing the event if the focused node is currently updating. + if (this.isUpdatingFocusedNode) return; + this.recentlyLostAllFocus = !element; let newNode: IFocusableNode | null | undefined = null; if (element instanceof HTMLElement || element instanceof SVGElement) { @@ -240,7 +244,23 @@ export class FocusManager { */ focusNode(focusableNode: IFocusableNode): void { this.ensureManagerIsUnlocked(); - if (this.focusedNode === focusableNode) return; // State is unchanged. + if (!this.currentlyHoldsEphemeralFocus) { + // Disable state syncing from DOM events since possible calls to focus() + // below will loop a call back to focusNode(). + this.isUpdatingFocusedNode = true; + } + + // Double check that state wasn't desynchronized in the background. See: + // https://github.com/google/blockly-keyboard-experimentation/issues/87. + // This is only done for the case where the same node is being focused twice + // since other cases should automatically correct (due to the rest of the + // routine running as normal). + const prevFocusedElement = this.focusedNode?.getFocusableElement(); + const hasDesyncedState = prevFocusedElement !== document.activeElement; + if (this.focusedNode === focusableNode && !hasDesyncedState) { + return; // State is unchanged. + } + if (!focusableNode.canBeFocused()) { // This node can't be focused. console.warn("Trying to focus a node that can't be focused."); @@ -292,6 +312,10 @@ export class FocusManager { this.activelyFocusNode(nodeToFocus, prevTree ?? null); } this.updateFocusedNode(nodeToFocus); + if (!this.currentlyHoldsEphemeralFocus) { + // Reenable state syncing from DOM events. + this.isUpdatingFocusedNode = false; + } } /** diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index b1cfb029a..cd89d1351 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -419,6 +419,91 @@ suite('FocusManager', function () { const currentNode = this.focusManager.getFocusedNode(); assert.strictEqual(currentNode, this.testFocusableTree1Node2); }); + + test('restores focus when element quietly loses focus', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + // Remove the FocusManager's listeners to simulate not receiving a focus + // event when focus is lost. This can happen in Firefox and Safari when an + // element is removed and then re-added to the DOM. This is a contrived + // setup to achieve the same outcome on all browsers. For context, see: + // https://github.com/google/blockly-keyboard-experimentation/issues/87. + for (const registeredListener of this.globalDocumentEventListeners) { + const eventType = registeredListener.type; + const eventListener = registeredListener.listener; + document.removeEventListener(eventType, eventListener); + } + document.body.focus(); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + const currentNode = this.focusManager.getFocusedNode(); + const currentElem = currentNode?.getFocusableElement(); + assert.strictEqual(currentNode, this.testFocusableTree1Node1); + assert.strictEqual(document.activeElement, currentElem); + }); + + test('restores focus when element and new node focused', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + // Remove the FocusManager's listeners to simulate not receiving a focus + // event when focus is lost. This can happen in Firefox and Safari when an + // element is removed and then re-added to the DOM. This is a contrived + // setup to achieve the same outcome on all browsers. For context, see: + // https://github.com/google/blockly-keyboard-experimentation/issues/87. + for (const registeredListener of this.globalDocumentEventListeners) { + const eventType = registeredListener.type; + const eventListener = registeredListener.listener; + document.removeEventListener(eventType, eventListener); + } + document.body.focus(); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + const currentNode = this.focusManager.getFocusedNode(); + const currentElem = currentNode?.getFocusableElement(); + assert.strictEqual(currentNode, this.testFocusableTree1Node2); + assert.strictEqual(document.activeElement, currentElem); + }); + + test('for unfocused node calls onNodeFocus once', function () { + sinon.spy(this.testFocusableTree1Node1, 'onNodeFocus'); + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + assert.strictEqual(this.testFocusableTree1Node1.onNodeFocus.callCount, 1); + }); + + test('for previously focused node calls onNodeBlur once', function () { + sinon.spy(this.testFocusableTree1Node1, 'onNodeBlur'); + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + assert.strictEqual(this.testFocusableTree1Node1.onNodeBlur.callCount, 1); + }); + + test('for unfocused tree calls onTreeFocus once', function () { + sinon.spy(this.testFocusableTree1, 'onTreeFocus'); + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + assert.strictEqual(this.testFocusableTree1.onTreeFocus.callCount, 1); + }); + + test('for previously focused tree calls onTreeBlur once', function () { + sinon.spy(this.testFocusableTree1, 'onTreeBlur'); + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + assert.strictEqual(this.testFocusableTree1.onTreeBlur.callCount, 1); + }); }); suite('getFocusManager()', function () {