diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 5e6e0af48..d79db4b4b 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -62,7 +62,7 @@ export class FocusManager { if (newNode) { this.focusNode(newNode); } else { - // TODO: Set previous to passive if all trees are losing active focus. + this.defocusCurrentFocusedNode(); } }); } @@ -259,6 +259,16 @@ export class FocusManager { }; } + private defocusCurrentFocusedNode(): void { + // The current node will likely be defocused while ephemeral focus is held, + // but internal manager state shouldn't change since the node should be + // restored upon exiting ephemeral focus mode. + if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) { + this.setNodeToPassive(this.focusedNode); + this.focusedNode = null; + } + } + private setNodeToActive(node: IFocusableNode): void { const element = node.getFocusableElement(); dom.addClass(element, 'blocklyActiveFocus'); diff --git a/core/interfaces/i_focusable_tree.ts b/core/interfaces/i_focusable_tree.ts index 1a8ccf82b..9cedba732 100644 --- a/core/interfaces/i_focusable_tree.ts +++ b/core/interfaces/i_focusable_tree.ts @@ -40,6 +40,28 @@ export interface IFocusableTree { */ getRootFocusableNode(): IFocusableNode; + /** + * Returns all directly nested trees under this tree. + * + * Note that the returned list of trees doesn't need to be stable, however all + * returned trees *do* need to be registered with FocusManager. Additionally, + * this must return actual nested trees as omitting a nested tree will affect + * how focus changes map to a specific node and its tree, potentially leading + * to user confusion. + */ + getNestedTrees(): Array; + + /** + * Returns the IFocusableNode corresponding to the specified element ID, or + * null if there's no exact node within this tree with that ID or if the ID + * corresponds to the root of the tree. + * + * This will never match against nested trees. + * + * @param id The ID of the node's focusable HTMLElement or SVGElement. + */ + lookUpFocusableNode(id: string): IFocusableNode | null; + /** * Returns the IFocusableNode corresponding to the select element, or null if * the element does not have such a node. @@ -47,7 +69,11 @@ export interface IFocusableTree { * The provided element must have a non-null ID that conforms to the contract * mentioned in IFocusableNode. * - * This function may match against the root node of the tree. + * This function may match against the root node of the tree. It will also map + * against the nearest node to the provided element if the element does not + * have an exact matching corresponding node. This function filters out + * matches against nested trees, so long as they are represented in the return + * value of getNestedTrees. */ findFocusableNodeFor( element: HTMLElement | SVGElement, diff --git a/core/utils/focusable_tree_traverser.ts b/core/utils/focusable_tree_traverser.ts index b7465e884..eb6de1e05 100644 --- a/core/utils/focusable_tree_traverser.ts +++ b/core/utils/focusable_tree_traverser.ts @@ -6,6 +6,7 @@ import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import * as dom from '../utils/dom.js'; /** * A helper utility for IFocusableTree implementations to aid with common @@ -24,20 +25,29 @@ export class FocusableTreeTraverser { */ static findFocusedNode(tree: IFocusableTree): IFocusableNode | null { const root = tree.getRootFocusableNode().getFocusableElement(); - const activeElem = root.querySelector('.blocklyActiveFocus'); - let active: IFocusableNode | null = null; - if (activeElem instanceof HTMLElement || activeElem instanceof SVGElement) { - active = tree.findFocusableNodeFor(activeElem); + if ( + dom.hasClass(root, 'blocklyActiveFocus') || + dom.hasClass(root, 'blocklyPassiveFocus') + ) { + // The root has focus. + return tree.getRootFocusableNode(); } - const passiveElems = Array.from( - root.querySelectorAll('.blocklyPassiveFocus'), - ); - const passive = passiveElems.map((elem) => { - if (elem instanceof HTMLElement || elem instanceof SVGElement) { - return tree.findFocusableNodeFor(elem); - } else return null; - }); - return active || passive.find((node) => !!node) || null; + + const activeEl = root.querySelector('.blocklyActiveFocus'); + let active: IFocusableNode | null = null; + if (activeEl instanceof HTMLElement || activeEl instanceof SVGElement) { + active = tree.findFocusableNodeFor(activeEl); + } + + // At most there should be one passive indicator per tree (not considering + // subtrees). + const passiveEl = root.querySelector('.blocklyPassiveFocus'); + let passive: IFocusableNode | null = null; + if (passiveEl instanceof HTMLElement || passiveEl instanceof SVGElement) { + passive = tree.findFocusableNodeFor(passiveEl); + } + + return active ?? passive; } /** @@ -47,38 +57,42 @@ export class FocusableTreeTraverser { * * If the tree contains another nested IFocusableTree, the nested tree may be * traversed but its nodes will never be returned here per the contract of - * findChildById. - * - * findChildById is a provided callback that takes an element ID and maps it - * back to the corresponding IFocusableNode within the provided - * IFocusableTree. These IDs will match the contract specified in the - * documentation for IFocusableNode. This function must not return any node - * that doesn't directly belong to the node's nearest parent tree. + * IFocusableTree.lookUpFocusableNode. * * @param element The HTML or SVG element being sought. * @param tree The tree under which the provided element may be a descendant. - * @param findChildById The ID->IFocusableNode mapping callback that must - * follow the contract mentioned above. * @returns The matching IFocusableNode, or null if there is no match. */ static findFocusableNodeFor( element: HTMLElement | SVGElement, tree: IFocusableTree, - findChildById: (id: string) => IFocusableNode | null, ): IFocusableNode | null { + // First, match against subtrees. + const subTreeMatches = tree + .getNestedTrees() + .map((tree) => tree.findFocusableNodeFor(element)); + if (subTreeMatches.findIndex((match) => !!match) !== -1) { + // At least one subtree has a match for the element so it cannot be part + // of the outer tree. + return null; + } + + // Second, check against the tree's root. if (element === tree.getRootFocusableNode().getFocusableElement()) { return tree.getRootFocusableNode(); } - const matchedChildNode = findChildById(element.id); + + // Third, check if the element has a node. + const matchedChildNode = tree.lookUpFocusableNode(element.id) ?? null; + if (matchedChildNode) return matchedChildNode; + + // Fourth, recurse up to find the nearest tree/node if it's possible. const elementParent = element.parentElement; if (!matchedChildNode && elementParent) { - // Recurse up to find the nearest tree/node. - return FocusableTreeTraverser.findFocusableNodeFor( - elementParent, - tree, - findChildById, - ); + return FocusableTreeTraverser.findFocusableNodeFor(elementParent, tree); } - return matchedChildNode; + + // Otherwise, there's no matching node. + return null; } } diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index 86a19fd18..e18dbc79e 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -1,10 +1,13 @@ /** * @license - * Copyright 2020 Google LLC + * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {FocusManager} from '../../build/src/core/focus_manager.js'; +import { + FocusManager, + getFocusManager, +} from '../../build/src/core/focus_manager.js'; import {FocusableTreeTraverser} from '../../build/src/core/utils/focusable_tree_traverser.js'; import {assert} from '../../node_modules/chai/chai.js'; import { @@ -33,7 +36,7 @@ suite('FocusManager', function () { return tree; }; }; - const FocusableTreeImpl = function (rootElement) { + const FocusableTreeImpl = function (rootElement, nestedTrees) { this.idToNodeMap = {}; this.addNode = function (element) { @@ -50,19 +53,26 @@ suite('FocusManager', function () { return this.rootNode; }; + this.getNestedTrees = function () { + return nestedTrees; + }; + + this.lookUpFocusableNode = function (id) { + return this.idToNodeMap[id]; + }; + this.findFocusableNodeFor = function (element) { - return FocusableTreeTraverser.findFocusableNodeFor( - element, - this, - (id) => this.idToNodeMap[id], - ); + return FocusableTreeTraverser.findFocusableNodeFor(element, this); }; this.rootNode = this.addNode(rootElement); }; - const createFocusableTree = function (rootElementId) { - return new FocusableTreeImpl(document.getElementById(rootElementId)); + const createFocusableTree = function (rootElementId, nestedTrees) { + return new FocusableTreeImpl( + document.getElementById(rootElementId), + nestedTrees || [], + ); }; const createFocusableNode = function (tree, elementId) { return tree.addNode(document.getElementById(elementId)); @@ -81,11 +91,29 @@ suite('FocusManager', function () { this.testFocusableTree1, 'testFocusableTree1.node2', ); - this.testFocusableTree2 = createFocusableTree('testFocusableTree2'); + this.testFocusableNestedTree4 = createFocusableTree( + 'testFocusableNestedTree4', + ); + this.testFocusableNestedTree4Node1 = createFocusableNode( + this.testFocusableNestedTree4, + 'testFocusableNestedTree4.node1', + ); + this.testFocusableNestedTree5 = createFocusableTree( + 'testFocusableNestedTree5', + ); + this.testFocusableNestedTree5Node1 = createFocusableNode( + this.testFocusableNestedTree5, + 'testFocusableNestedTree5.node1', + ); + this.testFocusableTree2 = createFocusableTree('testFocusableTree2', [ + this.testFocusableNestedTree4, + this.testFocusableNestedTree5, + ]); this.testFocusableTree2Node1 = createFocusableNode( this.testFocusableTree2, 'testFocusableTree2.node1', ); + this.testFocusableGroup1 = createFocusableTree('testFocusableGroup1'); this.testFocusableGroup1Node1 = createFocusableNode( this.testFocusableGroup1, @@ -99,7 +127,16 @@ suite('FocusManager', function () { this.testFocusableGroup1, 'testFocusableGroup1.node2', ); - this.testFocusableGroup2 = createFocusableTree('testFocusableGroup2'); + this.testFocusableNestedGroup4 = createFocusableTree( + 'testFocusableNestedGroup4', + ); + this.testFocusableNestedGroup4Node1 = createFocusableNode( + this.testFocusableNestedGroup4, + 'testFocusableNestedGroup4.node1', + ); + this.testFocusableGroup2 = createFocusableTree('testFocusableGroup2', [ + this.testFocusableNestedGroup4, + ]); this.testFocusableGroup2Node1 = createFocusableNode( this.testFocusableGroup2, 'testFocusableGroup2.node1', @@ -128,6 +165,10 @@ suite('FocusManager', function () { removeFocusIndicators(document.getElementById('testFocusableTree1.node2')); removeFocusIndicators(document.getElementById('testFocusableTree2')); removeFocusIndicators(document.getElementById('testFocusableTree2.node1')); + removeFocusIndicators(document.getElementById('testFocusableNestedTree4')); + removeFocusIndicators( + document.getElementById('testFocusableNestedTree4.node1'), + ); removeFocusIndicators(document.getElementById('testFocusableGroup1')); removeFocusIndicators(document.getElementById('testFocusableGroup1.node1')); removeFocusIndicators( @@ -136,6 +177,13 @@ suite('FocusManager', function () { removeFocusIndicators(document.getElementById('testFocusableGroup1.node2')); removeFocusIndicators(document.getElementById('testFocusableGroup2')); removeFocusIndicators(document.getElementById('testFocusableGroup2.node1')); + removeFocusIndicators(document.getElementById('testFocusableNestedGroup4')); + removeFocusIndicators( + document.getElementById('testFocusableNestedGroup4.node1'), + ); + + // Reset the current active element. + document.body.focus(); }); /* Basic lifecycle tests. */ @@ -303,6 +351,43 @@ suite('FocusManager', function () { errorMsgRegex, ); }); + + test('focuses element', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.strictEqual(document.activeElement, nodeElem); + }); + + test('fires focusin event', function () { + let focusCount = 0; + const focusListener = () => focusCount++; + document.addEventListener('focusin', focusListener); + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + document.removeEventListener('focusin', focusListener); + + // There should be exactly 1 focus event fired from focusNode(). + assert.equal(focusCount, 1); + }); + }); + + suite('getFocusManager()', function () { + test('returns non-null manager', function () { + const manager = getFocusManager(); + + assert.isNotNull(manager); + }); + + test('returns the exact same instance in subsequent calls', function () { + const manager1 = getFocusManager(); + const manager2 = getFocusManager(); + + assert.strictEqual(manager2, manager1); + }); }); /* Focus tests for HTML trees. */ @@ -477,6 +562,43 @@ suite('FocusManager', function () { this.testFocusableTree2, ); }); + + test('nested tree focusTree()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusTree(this.testFocusableNestedTree4); + + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + }); }); suite('getFocusedNode()', function () { test('registered tree focusTree()ed no prev focus returns root node', function () { @@ -649,6 +771,43 @@ suite('FocusManager', function () { this.testFocusableTree2Node1, ); }); + + test('nested tree focusTree()ed with no prev focus returns nested root', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusTree(this.testFocusableNestedTree4); + + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4.getRootFocusableNode(), + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4Node1, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4Node1, + ); + }); }); suite('CSS classes', function () { test('registered tree focusTree()ed no prev focus root elem has active property', function () { @@ -990,6 +1149,65 @@ suite('FocusManager', function () { 'blocklyPassiveFocus', ); }); + + test('nested tree focusTree()ed with no prev root has active focus', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusTree(this.testFocusableNestedTree4); + + const rootElem = this.testFocusableNestedTree4 + .getRootFocusableNode() + .getFocusableElement(); + assert.include(Array.from(rootElem.classList), 'blocklyActiveFocus'); + assert.notInclude( + Array.from(rootElem.classList), + 'blocklyPassiveFocus', + ); + }); + + test('nested tree node focusNode()ed with no prev focus node has active focus', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + const nodeElem = + this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.include(Array.from(nodeElem.classList), 'blocklyActiveFocus'); + assert.notInclude( + Array.from(nodeElem.classList), + 'blocklyPassiveFocus', + ); + }); + + test('nested tree node focusNode()ed after parent focused prev has passive node has active', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + const prevNodeElem = this.testFocusableTree2Node1.getFocusableElement(); + const currNodeElem = + this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.notInclude( + Array.from(prevNodeElem.classList), + 'blocklyActiveFocus', + ); + assert.include( + Array.from(prevNodeElem.classList), + 'blocklyPassiveFocus', + ); + assert.include( + Array.from(currNodeElem.classList), + 'blocklyActiveFocus', + ); + assert.notInclude( + Array.from(currNodeElem.classList), + 'blocklyPassiveFocus', + ); + }); }); }); @@ -1092,12 +1310,21 @@ suite('FocusManager', function () { assert.isNull(this.focusManager.getFocusedTree()); }); - test('non-registered tree node focus()ed after registered node focused returns original tree', function () { + test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnregisteredFocusableTree3.node1').focus(); + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unfocusable element focus()ed after registered node focused returns original tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testUnfocusableElement').focus(); + assert.equal( this.focusManager.getFocusedTree(), this.testFocusableTree1, @@ -1149,7 +1376,7 @@ suite('FocusManager', function () { ); }); - test('unregistered tree focus()ed with prev node after unregistering still returns old tree', function () { + test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); document.getElementById('testFocusableTree1.node1').focus(); @@ -1158,10 +1385,46 @@ suite('FocusManager', function () { document.getElementById('testFocusableTree1.node1').focus(); - // Attempting to focus a now removed tree should have no effect. + // Attempting to focus a now removed tree should result in nothing being + // focused since the removed tree can have DOM focus, but that focus is + // ignored by FocusManager. + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('nested tree focusTree()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4').focus(); + assert.equal( this.focusManager.getFocusedTree(), - this.testFocusableTree2, + this.testFocusableNestedTree4, + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').focus(); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, ); }); }); @@ -1263,12 +1526,21 @@ suite('FocusManager', function () { assert.isNull(this.focusManager.getFocusedNode()); }); - test('non-registered tree node focus()ed after registered node focused returns original node', function () { + test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnregisteredFocusableTree3.node1').focus(); + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unfocuasble element focus()ed after registered node focused returns original node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testUnfocusableElement').focus(); + assert.equal( this.focusManager.getFocusedNode(), this.testFocusableTree1Node1, @@ -1320,7 +1592,7 @@ suite('FocusManager', function () { ); }); - test('unregistered tree focus()ed with prev node after unregistering still returns old node', function () { + test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); document.getElementById('testFocusableTree1.node1').focus(); @@ -1329,10 +1601,46 @@ suite('FocusManager', function () { document.getElementById('testFocusableTree1.node1').focus(); - // Attempting to focus a now removed tree should have no effect. + // Attempting to focus a now removed tree should result in nothing being + // focused since the removed tree can have DOM focus, but that focus is + // ignored by FocusManager. + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('nested tree focus()ed with no prev focus returns nested root', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4').focus(); + assert.equal( this.focusManager.getFocusedNode(), - this.testFocusableTree2Node1, + this.testFocusableNestedTree4.getRootFocusableNode(), + ); + }); + + test('nested tree node focus()ed with no prev focus returns focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4Node1, + ); + }); + + test('nested tree node focus()ed after parent focused returns focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').focus(); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4Node1, ); }); }); @@ -1492,17 +1800,17 @@ suite('FocusManager', function () { ); }); - test('non-registered tree node focus()ed after registered node focused original node has active focus', function () { + test('unfocsable element focus()ed after registered node focused original node has active focus', function () { this.focusManager.registerTree(this.testFocusableTree1); document.getElementById('testFocusableTree1.node1').focus(); - document.getElementById('testUnregisteredFocusableTree3.node1').focus(); + document.getElementById('testUnfocusableElement').focus(); // The original node should be unchanged, and the unregistered node should not have any // focus indicators. const nodeElem = document.getElementById('testFocusableTree1.node1'); const attemptedNewNodeElem = document.getElementById( - 'testUnregisteredFocusableTree3.node1', + 'testUnfocusableElement', ); assert.include(Array.from(nodeElem.classList), 'blocklyActiveFocus'); assert.notInclude( @@ -1615,7 +1923,7 @@ suite('FocusManager', function () { ); }); - test('unregistered tree focus()ed with prev node after unregistering does not change indicators', function () { + test('unregistered tree focus()ed with prev node after unregistering removes active indicator', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); document.getElementById('testFocusableTree1.node1').focus(); @@ -1624,16 +1932,16 @@ suite('FocusManager', function () { document.getElementById('testFocusableTree1.node1').focus(); - // Attempting to focus a now removed tree should have no effect. + // Attempting to focus a now removed tree should remove active. const otherNodeElem = this.testFocusableTree2Node1.getFocusableElement(); const removedNodeElem = this.testFocusableTree1Node1.getFocusableElement(); - assert.include( + assert.notInclude( Array.from(otherNodeElem.classList), 'blocklyActiveFocus', ); - assert.notInclude( + assert.include( Array.from(otherNodeElem.classList), 'blocklyPassiveFocus', ); @@ -1712,6 +2020,65 @@ suite('FocusManager', function () { 'blocklyPassiveFocus', ); }); + + test('nested tree focus()ed with no prev root has active focus', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4').focus(); + + const rootElem = this.testFocusableNestedTree4 + .getRootFocusableNode() + .getFocusableElement(); + assert.include(Array.from(rootElem.classList), 'blocklyActiveFocus'); + assert.notInclude( + Array.from(rootElem.classList), + 'blocklyPassiveFocus', + ); + }); + + test('nested tree node focus()ed with no prev focus node has active focus', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + const nodeElem = + this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.include(Array.from(nodeElem.classList), 'blocklyActiveFocus'); + assert.notInclude( + Array.from(nodeElem.classList), + 'blocklyPassiveFocus', + ); + }); + + test('nested tree node focus()ed after parent focused prev has passive node has active', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').focus(); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + const prevNodeElem = this.testFocusableTree2Node1.getFocusableElement(); + const currNodeElem = + this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.notInclude( + Array.from(prevNodeElem.classList), + 'blocklyActiveFocus', + ); + assert.include( + Array.from(prevNodeElem.classList), + 'blocklyPassiveFocus', + ); + assert.include( + Array.from(currNodeElem.classList), + 'blocklyActiveFocus', + ); + assert.notInclude( + Array.from(currNodeElem.classList), + 'blocklyPassiveFocus', + ); + }); }); }); @@ -1887,6 +2254,43 @@ suite('FocusManager', function () { this.testFocusableGroup2, ); }); + + test('nested tree focusTree()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusTree(this.testFocusableNestedGroup4); + + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, + ); + }); }); suite('getFocusedNode()', function () { test('registered tree focusTree()ed no prev focus returns root node', function () { @@ -2059,6 +2463,43 @@ suite('FocusManager', function () { this.testFocusableGroup2Node1, ); }); + + test('nested tree focusTree()ed with no prev focus returns nested root', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusTree(this.testFocusableNestedGroup4); + + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4.getRootFocusableNode(), + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns focused node', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4Node1, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns focused node', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4Node1, + ); + }); }); suite('CSS classes', function () { test('registered tree focusTree()ed no prev focus root elem has active property', function () { @@ -2402,6 +2843,66 @@ suite('FocusManager', function () { 'blocklyPassiveFocus', ); }); + + test('nested tree focusTree()ed with no prev root has active focus', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusTree(this.testFocusableNestedGroup4); + + const rootElem = this.testFocusableNestedGroup4 + .getRootFocusableNode() + .getFocusableElement(); + assert.include(Array.from(rootElem.classList), 'blocklyActiveFocus'); + assert.notInclude( + Array.from(rootElem.classList), + 'blocklyPassiveFocus', + ); + }); + + test('nested tree node focusNode()ed with no prev focus node has active focus', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + const nodeElem = + this.testFocusableNestedGroup4Node1.getFocusableElement(); + assert.include(Array.from(nodeElem.classList), 'blocklyActiveFocus'); + assert.notInclude( + Array.from(nodeElem.classList), + 'blocklyPassiveFocus', + ); + }); + + test('nested tree node focusNode()ed after parent focused prev has passive node has active', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + const prevNodeElem = + this.testFocusableGroup2Node1.getFocusableElement(); + const currNodeElem = + this.testFocusableNestedGroup4Node1.getFocusableElement(); + assert.notInclude( + Array.from(prevNodeElem.classList), + 'blocklyActiveFocus', + ); + assert.include( + Array.from(prevNodeElem.classList), + 'blocklyPassiveFocus', + ); + assert.include( + Array.from(currNodeElem.classList), + 'blocklyActiveFocus', + ); + assert.notInclude( + Array.from(currNodeElem.classList), + 'blocklyPassiveFocus', + ); + }); }); }); @@ -2506,7 +3007,7 @@ suite('FocusManager', function () { assert.isNull(this.focusManager.getFocusedTree()); }); - test('non-registered tree node focus()ed after registered node focused returns original tree', function () { + test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); document.getElementById('testFocusableGroup1.node1').focus(); @@ -2514,10 +3015,10 @@ suite('FocusManager', function () { .getElementById('testUnregisteredFocusableGroup3.node1') .focus(); - assert.equal( - this.focusManager.getFocusedTree(), - this.testFocusableGroup1, - ); + // Attempting to focus a now removed tree should result in nothing being + // focused since the removed tree can have DOM focus, but that focus is + // ignored by FocusManager. + assert.isNull(this.focusManager.getFocusedTree()); }); test('unregistered tree focus()ed with no prev focus returns null', function () { @@ -2565,7 +3066,7 @@ suite('FocusManager', function () { ); }); - test('unregistered tree focus()ed with prev node after unregistering still returns old tree', function () { + test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); document.getElementById('testFocusableGroup1.node1').focus(); @@ -2574,10 +3075,46 @@ suite('FocusManager', function () { document.getElementById('testFocusableGroup1.node1').focus(); - // Attempting to focus a now removed tree should have no effect. + // Attempting to focus a now removed tree should result in nothing being + // focused since the removed tree can have DOM focus, but that focus is + // ignored by FocusManager. + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('nested tree focusTree()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4').focus(); + assert.equal( this.focusManager.getFocusedTree(), - this.testFocusableGroup2, + this.testFocusableNestedGroup4, + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').focus(); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, ); }); }); @@ -2681,7 +3218,7 @@ suite('FocusManager', function () { assert.isNull(this.focusManager.getFocusedNode()); }); - test('non-registered tree node focus()ed after registered node focused returns original node', function () { + test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); document.getElementById('testFocusableGroup1.node1').focus(); @@ -2689,6 +3226,15 @@ suite('FocusManager', function () { .getElementById('testUnregisteredFocusableGroup3.node1') .focus(); + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unfocusable element focus()ed after registered node focused returns original node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testUnfocusableElement').focus(); + assert.equal( this.focusManager.getFocusedNode(), this.testFocusableGroup1Node1, @@ -2740,7 +3286,7 @@ suite('FocusManager', function () { ); }); - test('unregistered tree focus()ed with prev node after unregistering still returns old node', function () { + test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); document.getElementById('testFocusableGroup1.node1').focus(); @@ -2749,10 +3295,46 @@ suite('FocusManager', function () { document.getElementById('testFocusableGroup1.node1').focus(); - // Attempting to focus a now removed tree should have no effect. + // Attempting to focus a now removed tree should result in nothing being + // focused since the removed tree can have DOM focus, but that focus is + // ignored by FocusManager. + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('nested tree focus()ed with no prev focus returns nested root', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4').focus(); + assert.equal( this.focusManager.getFocusedNode(), - this.testFocusableGroup2Node1, + this.testFocusableNestedGroup4.getRootFocusableNode(), + ); + }); + + test('nested tree node focus()ed with no prev focus returns focused node', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4Node1, + ); + }); + + test('nested tree node focus()ed after parent focused returns focused node', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').focus(); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4Node1, ); }); }); @@ -2916,19 +3498,17 @@ suite('FocusManager', function () { ); }); - test('non-registered tree node focus()ed after registered node focused original node has active focus', function () { + test('unfocusable element focus()ed after registered node focused original node has active focus', function () { this.focusManager.registerTree(this.testFocusableGroup1); document.getElementById('testFocusableGroup1.node1').focus(); - document - .getElementById('testUnregisteredFocusableGroup3.node1') - .focus(); + document.getElementById('testUnfocusableElement').focus(); // The original node should be unchanged, and the unregistered node should not have any // focus indicators. const nodeElem = document.getElementById('testFocusableGroup1.node1'); const attemptedNewNodeElem = document.getElementById( - 'testUnregisteredFocusableGroup3.node1', + 'testUnfocusableElement', ); assert.include(Array.from(nodeElem.classList), 'blocklyActiveFocus'); assert.notInclude( @@ -3041,7 +3621,7 @@ suite('FocusManager', function () { ); }); - test('unregistered tree focus()ed with prev node after unregistering does not change indicators', function () { + test('unregistered tree focus()ed with prev node after unregistering removes active indicator', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); document.getElementById('testFocusableGroup1.node1').focus(); @@ -3050,16 +3630,16 @@ suite('FocusManager', function () { document.getElementById('testFocusableGroup1.node1').focus(); - // Attempting to focus a now removed tree should have no effect. + // Attempting to focus a now removed tree should remove active. const otherNodeElem = this.testFocusableGroup2Node1.getFocusableElement(); const removedNodeElem = this.testFocusableGroup1Node1.getFocusableElement(); - assert.include( + assert.notInclude( Array.from(otherNodeElem.classList), 'blocklyActiveFocus', ); - assert.notInclude( + assert.include( Array.from(otherNodeElem.classList), 'blocklyPassiveFocus', ); @@ -3138,6 +3718,163 @@ suite('FocusManager', function () { 'blocklyPassiveFocus', ); }); + + test('nested tree focus()ed with no prev root has active focus', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4').focus(); + + const rootElem = this.testFocusableNestedGroup4 + .getRootFocusableNode() + .getFocusableElement(); + assert.include(Array.from(rootElem.classList), 'blocklyActiveFocus'); + assert.notInclude( + Array.from(rootElem.classList), + 'blocklyPassiveFocus', + ); + }); + + test('nested tree node focus()ed with no prev focus node has active focus', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + const nodeElem = + this.testFocusableNestedGroup4Node1.getFocusableElement(); + assert.include(Array.from(nodeElem.classList), 'blocklyActiveFocus'); + assert.notInclude( + Array.from(nodeElem.classList), + 'blocklyPassiveFocus', + ); + }); + + test('nested tree node focus()ed after parent focused prev has passive node has active', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').focus(); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + const prevNodeElem = + this.testFocusableGroup2Node1.getFocusableElement(); + const currNodeElem = + this.testFocusableNestedGroup4Node1.getFocusableElement(); + assert.notInclude( + Array.from(prevNodeElem.classList), + 'blocklyActiveFocus', + ); + assert.include( + Array.from(prevNodeElem.classList), + 'blocklyPassiveFocus', + ); + assert.include( + Array.from(currNodeElem.classList), + 'blocklyActiveFocus', + ); + assert.notInclude( + Array.from(currNodeElem.classList), + 'blocklyPassiveFocus', + ); + }); + }); + }); + + /* High-level focus/defocusing tests. */ + suite('Defocusing and refocusing', function () { + test('Defocusing actively focused root HTML tree switches to passive highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusTree(this.testFocusableTree2); + + document.getElementById('testUnregisteredFocusableTree3').focus(); + + const rootNode = this.testFocusableTree2.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + assert.isNull(this.focusManager.getFocusedTree()); + assert.isNull(this.focusManager.getFocusedNode()); + assert.include(Array.from(rootElem.classList), 'blocklyPassiveFocus'); + assert.notInclude(Array.from(rootElem.classList), 'blocklyActiveFocus'); + }); + + test('Defocusing actively focused HTML tree node switches to passive highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + document.getElementById('testUnregisteredFocusableTree3').focus(); + + const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.isNull(this.focusManager.getFocusedTree()); + assert.isNull(this.focusManager.getFocusedNode()); + assert.include(Array.from(nodeElem.classList), 'blocklyPassiveFocus'); + assert.notInclude(Array.from(nodeElem.classList), 'blocklyActiveFocus'); + }); + + test('Defocusing actively focused HTML subtree node switches to passive highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + document.getElementById('testUnregisteredFocusableTree3').focus(); + + const nodeElem = this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.isNull(this.focusManager.getFocusedTree()); + assert.isNull(this.focusManager.getFocusedNode()); + assert.include(Array.from(nodeElem.classList), 'blocklyPassiveFocus'); + assert.notInclude(Array.from(nodeElem.classList), 'blocklyActiveFocus'); + }); + + test('Refocusing actively focused root HTML tree restores to active highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusTree(this.testFocusableTree2); + document.getElementById('testUnregisteredFocusableTree3').focus(); + + document.getElementById('testFocusableTree2').focus(); + + const rootNode = this.testFocusableTree2.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + assert.equal(this.focusManager.getFocusedTree(), this.testFocusableTree2); + assert.equal(this.focusManager.getFocusedNode(), rootNode); + assert.notInclude(Array.from(rootElem.classList), 'blocklyPassiveFocus'); + assert.include(Array.from(rootElem.classList), 'blocklyActiveFocus'); + }); + + test('Refocusing actively focused HTML tree node restores to active highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testUnregisteredFocusableTree3').focus(); + + document.getElementById('testFocusableTree2.node1').focus(); + + const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.equal(this.focusManager.getFocusedTree(), this.testFocusableTree2); + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.notInclude(Array.from(nodeElem.classList), 'blocklyPassiveFocus'); + assert.include(Array.from(nodeElem.classList), 'blocklyActiveFocus'); + }); + + test('Refocusing actively focused HTML subtree node restores to active highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + document.getElementById('testUnregisteredFocusableTree3').focus(); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + const nodeElem = this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.equal( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + assert.equal( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4Node1, + ); + assert.notInclude(Array.from(nodeElem.classList), 'blocklyPassiveFocus'); + assert.include(Array.from(nodeElem.classList), 'blocklyActiveFocus'); }); }); diff --git a/tests/mocha/focusable_tree_traverser_test.js b/tests/mocha/focusable_tree_traverser_test.js new file mode 100644 index 000000000..2069132fe --- /dev/null +++ b/tests/mocha/focusable_tree_traverser_test.js @@ -0,0 +1,480 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FocusableTreeTraverser} from '../../build/src/core/utils/focusable_tree_traverser.js'; +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('FocusableTreeTraverser', function () { + setup(function () { + sharedTestSetup.call(this); + + const FocusableNodeImpl = function (element, tree) { + this.getFocusableElement = function () { + return element; + }; + + this.getFocusableTree = function () { + return tree; + }; + }; + const FocusableTreeImpl = function (rootElement, nestedTrees) { + this.idToNodeMap = {}; + + this.addNode = function (element) { + const node = new FocusableNodeImpl(element, this); + this.idToNodeMap[element.id] = node; + return node; + }; + + this.getFocusedNode = function () { + throw Error('Unused in test suite.'); + }; + + this.getRootFocusableNode = function () { + return this.rootNode; + }; + + this.getNestedTrees = function () { + return nestedTrees; + }; + + this.lookUpFocusableNode = function (id) { + return this.idToNodeMap[id]; + }; + + this.findFocusableNodeFor = function (element) { + return FocusableTreeTraverser.findFocusableNodeFor(element, this); + }; + + this.rootNode = this.addNode(rootElement); + }; + + const createFocusableTree = function (rootElementId, nestedTrees) { + return new FocusableTreeImpl( + document.getElementById(rootElementId), + nestedTrees || [], + ); + }; + const createFocusableNode = function (tree, elementId) { + return tree.addNode(document.getElementById(elementId)); + }; + + this.testFocusableTree1 = createFocusableTree('testFocusableTree1'); + this.testFocusableTree1Node1 = createFocusableNode( + this.testFocusableTree1, + 'testFocusableTree1.node1', + ); + this.testFocusableTree1Node1Child1 = createFocusableNode( + this.testFocusableTree1, + 'testFocusableTree1.node1.child1', + ); + this.testFocusableTree1Node2 = createFocusableNode( + this.testFocusableTree1, + 'testFocusableTree1.node2', + ); + this.testFocusableNestedTree4 = createFocusableTree( + 'testFocusableNestedTree4', + ); + this.testFocusableNestedTree4Node1 = createFocusableNode( + this.testFocusableNestedTree4, + 'testFocusableNestedTree4.node1', + ); + this.testFocusableNestedTree5 = createFocusableTree( + 'testFocusableNestedTree5', + ); + this.testFocusableNestedTree5Node1 = createFocusableNode( + this.testFocusableNestedTree5, + 'testFocusableNestedTree5.node1', + ); + this.testFocusableTree2 = createFocusableTree('testFocusableTree2', [ + this.testFocusableNestedTree4, + this.testFocusableNestedTree5, + ]); + this.testFocusableTree2Node1 = createFocusableNode( + this.testFocusableTree2, + 'testFocusableTree2.node1', + ); + }); + + teardown(function () { + sharedTestTeardown.call(this); + + const removeFocusIndicators = function (element) { + element.classList.remove('blocklyActiveFocus', 'blocklyPassiveFocus'); + }; + + // Ensure all node CSS styles are reset so that state isn't leaked between tests. + removeFocusIndicators(document.getElementById('testFocusableTree1')); + removeFocusIndicators(document.getElementById('testFocusableTree1.node1')); + removeFocusIndicators( + document.getElementById('testFocusableTree1.node1.child1'), + ); + removeFocusIndicators(document.getElementById('testFocusableTree1.node2')); + removeFocusIndicators(document.getElementById('testFocusableTree2')); + removeFocusIndicators(document.getElementById('testFocusableTree2.node1')); + removeFocusIndicators(document.getElementById('testFocusableNestedTree4')); + removeFocusIndicators( + document.getElementById('testFocusableNestedTree4.node1'), + ); + removeFocusIndicators(document.getElementById('testFocusableNestedTree5')); + removeFocusIndicators( + document.getElementById('testFocusableNestedTree5.node1'), + ); + }); + + suite('findFocusedNode()', function () { + test('for tree with no highlights returns null', function () { + const tree = this.testFocusableTree1; + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.isNull(finding); + }); + + test('for tree with root active highlight returns root node', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + rootNode.getFocusableElement().classList.add('blocklyActiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.equal(finding, rootNode); + }); + + test('for tree with root passive highlight returns root node', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + rootNode.getFocusableElement().classList.add('blocklyPassiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.equal(finding, rootNode); + }); + + test('for tree with node active highlight returns node', function () { + const tree = this.testFocusableTree1; + const node = this.testFocusableTree1Node1; + node.getFocusableElement().classList.add('blocklyActiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.equal(finding, node); + }); + + test('for tree with node passive highlight returns node', function () { + const tree = this.testFocusableTree1; + const node = this.testFocusableTree1Node1; + node.getFocusableElement().classList.add('blocklyPassiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.equal(finding, node); + }); + + test('for tree with nested node active highlight returns node', function () { + const tree = this.testFocusableTree1; + const node = this.testFocusableTree1Node1Child1; + node.getFocusableElement().classList.add('blocklyActiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.equal(finding, node); + }); + + test('for tree with nested node passive highlight returns node', function () { + const tree = this.testFocusableTree1; + const node = this.testFocusableTree1Node1Child1; + node.getFocusableElement().classList.add('blocklyPassiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.equal(finding, node); + }); + + test('for tree with nested tree root active no parent highlights returns null', function () { + const tree = this.testFocusableNestedTree4; + const rootNode = this.testFocusableNestedTree4.getRootFocusableNode(); + rootNode.getFocusableElement().classList.add('blocklyActiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.equal(finding, rootNode); + }); + + test('for tree with nested tree root passive no parent highlights returns null', function () { + const tree = this.testFocusableNestedTree4; + const rootNode = this.testFocusableNestedTree4.getRootFocusableNode(); + rootNode.getFocusableElement().classList.add('blocklyPassiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.equal(finding, rootNode); + }); + + test('for tree with nested tree root active no parent highlights returns null', function () { + const tree = this.testFocusableNestedTree4; + const node = this.testFocusableNestedTree4Node1; + node.getFocusableElement().classList.add('blocklyActiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.equal(finding, node); + }); + + test('for tree with nested tree root passive no parent highlights returns null', function () { + const tree = this.testFocusableNestedTree4; + const node = this.testFocusableNestedTree4Node1; + node.getFocusableElement().classList.add('blocklyPassiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.equal(finding, node); + }); + + test('for tree with nested tree root active parent node passive returns parent node', function () { + const tree = this.testFocusableNestedTree4; + const rootNode = this.testFocusableNestedTree4.getRootFocusableNode(); + this.testFocusableTree2Node1 + .getFocusableElement() + .classList.add('blocklyPassiveFocus'); + rootNode.getFocusableElement().classList.add('blocklyActiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode( + this.testFocusableTree2, + ); + + assert.equal(finding, this.testFocusableTree2Node1); + }); + + test('for tree with nested tree root passive parent node passive returns parent node', function () { + const tree = this.testFocusableNestedTree4; + const rootNode = this.testFocusableNestedTree4.getRootFocusableNode(); + this.testFocusableTree2Node1 + .getFocusableElement() + .classList.add('blocklyPassiveFocus'); + rootNode.getFocusableElement().classList.add('blocklyPassiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode( + this.testFocusableTree2, + ); + + assert.equal(finding, this.testFocusableTree2Node1); + }); + + test('for tree with nested tree node active parent node passive returns parent node', function () { + const tree = this.testFocusableNestedTree4; + const node = this.testFocusableNestedTree4Node1; + this.testFocusableTree2Node1 + .getFocusableElement() + .classList.add('blocklyPassiveFocus'); + node.getFocusableElement().classList.add('blocklyActiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode( + this.testFocusableTree2, + ); + + assert.equal(finding, this.testFocusableTree2Node1); + }); + + test('for tree with nested tree node passive parent node passive returns parent node', function () { + const tree = this.testFocusableNestedTree4; + const node = this.testFocusableNestedTree4Node1; + this.testFocusableTree2Node1 + .getFocusableElement() + .classList.add('blocklyPassiveFocus'); + node.getFocusableElement().classList.add('blocklyPassiveFocus'); + + const finding = FocusableTreeTraverser.findFocusedNode( + this.testFocusableTree2, + ); + + assert.equal(finding, this.testFocusableTree2Node1); + }); + }); + + suite('findFocusableNodeFor()', function () { + test('for root element returns root', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + + assert.equal(finding, rootNode); + }); + + test('for element for different tree root returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = this.testFocusableTree2.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + + assert.isNull(finding); + }); + + test('for element for different tree node returns null', function () { + const tree = this.testFocusableTree1; + const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + assert.isNull(finding); + }); + + test('for node element in tree returns node', function () { + const tree = this.testFocusableTree1; + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + assert.equal(finding, this.testFocusableTree1Node1); + }); + + test('for non-node element in tree returns root', function () { + const tree = this.testFocusableTree1; + const unregElem = document.getElementById( + 'testFocusableTree1.node2.unregisteredChild1', + ); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + unregElem, + tree, + ); + + // An unregistered element should map to the closest node. + assert.equal(finding, this.testFocusableTree1Node2); + }); + + test('for nested node element in tree returns node', function () { + const tree = this.testFocusableTree1; + const nodeElem = this.testFocusableTree1Node1Child1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + // The nested node should be returned. + assert.equal(finding, this.testFocusableTree1Node1Child1); + }); + + test('for nested node element in tree returns node', function () { + const tree = this.testFocusableTree1; + const unregElem = document.getElementById( + 'testFocusableTree1.node1.child1.unregisteredChild1', + ); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + unregElem, + tree, + ); + + // An unregistered element should map to the closest node. + assert.equal(finding, this.testFocusableTree1Node1Child1); + }); + + test('for nested node element in tree returns node', function () { + const tree = this.testFocusableTree1; + const unregElem = document.getElementById( + 'testFocusableTree1.unregisteredChild1', + ); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + unregElem, + tree, + ); + + // An unregistered element should map to the closest node (or root). + assert.equal(finding, tree.getRootFocusableNode()); + }); + + test('for nested tree root returns nested tree root', function () { + const tree = this.testFocusableNestedTree4; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + + assert.equal(finding, rootNode); + }); + + test('for nested tree node returns nested tree node', function () { + const tree = this.testFocusableNestedTree4; + const nodeElem = this.testFocusableNestedTree4Node1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + // The node of the nested tree should be returned. + assert.equal(finding, this.testFocusableNestedTree4Node1); + }); + + test('for nested element in nested tree node returns nearest nested node', function () { + const tree = this.testFocusableNestedTree4; + const unregElem = document.getElementById( + 'testFocusableNestedTree4.node1.unregisteredChild1', + ); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + unregElem, + tree, + ); + + // An unregistered element should map to the closest node. + assert.equal(finding, this.testFocusableNestedTree4Node1); + }); + + test('for nested tree node under root with different tree base returns null', function () { + const tree = this.testFocusableTree2; + const nodeElem = this.testFocusableNestedTree5Node1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + // The nested node hierarchically sits below the outer tree, but using + // that tree as the basis should yield null since it's not a direct child. + assert.isNull(finding); + }); + + test('for nested tree node under node with different tree base returns null', function () { + const tree = this.testFocusableTree2; + const nodeElem = this.testFocusableNestedTree4Node1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + // The nested node hierarchically sits below the outer tree, but using + // that tree as the basis should yield null since it's not a direct child. + assert.isNull(finding); + }); + }); +}); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 2eb42869a..17e15d2c7 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -35,26 +35,57 @@ fill: #00f; } - +
-
+ Focusable tree 1 +
Tree 1 node 1 -
Tree 1 node 1 child 1
+
+ Tree 1 node 1 child 1 +
+ Tree 1 node 1 child 1 child 1 (unregistered) +
+
-
+
Tree 1 node 2 -
Tree 1 node 2 child 2 (unregistered)
+
+ Tree 1 node 2 child 2 (unregistered) +
+
+
+ Tree 1 child 1 (unregistered)
-
Tree 2 node 1
+ Focusable tree 2 +
+ Tree 2 node 1 +
+ Nested tree 4 +
+ Tree 4 node 1 (nested) +
+ Tree 4 node 1 child 1 (unregistered) +
+
+
+
+
+ Nested tree 5 +
Tree 5 node 1 (nested)
+
-
Tree 3 node 1 (unregistered)
+ Unregistered tree 3 +
+ Tree 3 node 1 (unregistered) +
+
Unfocusable element
@@ -71,7 +102,9 @@ Group 1 node 2 - Tree 1 node 2 child 2 (unregistered) + + Tree 1 node 2 child 2 (unregistered) + @@ -80,11 +113,19 @@ Group 2 node 1 + + + + Group 4 node 1 (nested) + + - - Tree 3 node 1 (unregistered) + + + Tree 3 node 1 (unregistered) + @@ -162,6 +203,7 @@ import './field_variable_test.js'; import './flyout_test.js'; import './focus_manager_test.js'; + import './focusable_tree_traverser_test.js'; // import './test_event_reduction.js'; import './generator_test.js'; import './gesture_test.js';