mirror of
https://github.com/google/blockly.git
synced 2026-01-08 09:30:06 +01:00
This introduces new callback methods for IFocusableTree and IFocusableNode for providing a basis of synchronizing domain state with focus changes. It also introduces support for implementations of IFocusableTree to better manage initial state cases, especially when a tree is focused using tab navigation. FocusManager has also been updated to ensure functional parity between tab-navigating to a tree and using focusTree() on that tree. This means that tab navigating to a tree will actually restore focus back to that tree's previous focused node rather than the root (unless the root is navigated to from within the tree itself). This is meant to provide better consistency between tab and non-tab keyboard navigation. Note that these changes originally came from #8875 and are required for later PRs that will introduce IFocusableNode and IFocusableTree implementations.
525 lines
17 KiB
JavaScript
525 lines
17 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {FocusManager} 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 {
|
|
sharedTestSetup,
|
|
sharedTestTeardown,
|
|
} from './test_helpers/setup_teardown.js';
|
|
|
|
class FocusableNodeImpl {
|
|
constructor(element, tree) {
|
|
this.element = element;
|
|
this.tree = tree;
|
|
}
|
|
|
|
getFocusableElement() {
|
|
return this.element;
|
|
}
|
|
|
|
getFocusableTree() {
|
|
return this.tree;
|
|
}
|
|
|
|
onNodeFocus() {}
|
|
|
|
onNodeBlur() {}
|
|
}
|
|
|
|
class FocusableTreeImpl {
|
|
constructor(rootElement, nestedTrees) {
|
|
this.nestedTrees = nestedTrees;
|
|
this.idToNodeMap = {};
|
|
this.rootNode = this.addNode(rootElement);
|
|
}
|
|
|
|
addNode(element) {
|
|
const node = new FocusableNodeImpl(element, this);
|
|
this.idToNodeMap[element.id] = node;
|
|
return node;
|
|
}
|
|
|
|
getRootFocusableNode() {
|
|
return this.rootNode;
|
|
}
|
|
|
|
getRestoredFocusableNode() {
|
|
return null;
|
|
}
|
|
|
|
getNestedTrees() {
|
|
return this.nestedTrees;
|
|
}
|
|
|
|
lookUpFocusableNode(id) {
|
|
return this.idToNodeMap[id];
|
|
}
|
|
|
|
onTreeFocus() {}
|
|
|
|
onTreeBlur() {}
|
|
}
|
|
|
|
suite('FocusableTreeTraverser', function () {
|
|
setup(function () {
|
|
sharedTestSetup.call(this);
|
|
|
|
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(
|
|
FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME,
|
|
FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME,
|
|
);
|
|
};
|
|
|
|
// 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(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(tree);
|
|
|
|
assert.strictEqual(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(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(tree);
|
|
|
|
assert.strictEqual(finding, rootNode);
|
|
});
|
|
|
|
test('for tree with node active highlight returns node', function () {
|
|
const tree = this.testFocusableTree1;
|
|
const node = this.testFocusableTree1Node1;
|
|
node
|
|
.getFocusableElement()
|
|
.classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(tree);
|
|
|
|
assert.strictEqual(finding, node);
|
|
});
|
|
|
|
test('for tree with node passive highlight returns node', function () {
|
|
const tree = this.testFocusableTree1;
|
|
const node = this.testFocusableTree1Node1;
|
|
node
|
|
.getFocusableElement()
|
|
.classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(tree);
|
|
|
|
assert.strictEqual(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(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(tree);
|
|
|
|
assert.strictEqual(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(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(tree);
|
|
|
|
assert.strictEqual(finding, node);
|
|
});
|
|
|
|
test('for tree with nested tree root active no parent highlights returns root', function () {
|
|
const tree = this.testFocusableNestedTree4;
|
|
const rootNode = this.testFocusableNestedTree4.getRootFocusableNode();
|
|
rootNode
|
|
.getFocusableElement()
|
|
.classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(tree);
|
|
|
|
assert.strictEqual(finding, rootNode);
|
|
});
|
|
|
|
test('for tree with nested tree root passive no parent highlights returns root', function () {
|
|
const tree = this.testFocusableNestedTree4;
|
|
const rootNode = this.testFocusableNestedTree4.getRootFocusableNode();
|
|
rootNode
|
|
.getFocusableElement()
|
|
.classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(tree);
|
|
|
|
assert.strictEqual(finding, rootNode);
|
|
});
|
|
|
|
test('for tree with nested tree node active no parent highlights returns node', function () {
|
|
const tree = this.testFocusableNestedTree4;
|
|
const node = this.testFocusableNestedTree4Node1;
|
|
node
|
|
.getFocusableElement()
|
|
.classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(tree);
|
|
|
|
assert.strictEqual(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(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(tree);
|
|
|
|
assert.strictEqual(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(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
rootNode
|
|
.getFocusableElement()
|
|
.classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(
|
|
this.testFocusableTree2,
|
|
);
|
|
|
|
assert.strictEqual(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(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
rootNode
|
|
.getFocusableElement()
|
|
.classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(
|
|
this.testFocusableTree2,
|
|
);
|
|
|
|
assert.strictEqual(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(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
node
|
|
.getFocusableElement()
|
|
.classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(
|
|
this.testFocusableTree2,
|
|
);
|
|
|
|
assert.strictEqual(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(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
node
|
|
.getFocusableElement()
|
|
.classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
|
|
|
|
const finding = FocusableTreeTraverser.findFocusedNode(
|
|
this.testFocusableTree2,
|
|
);
|
|
|
|
assert.strictEqual(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.strictEqual(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.strictEqual(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.strictEqual(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.strictEqual(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.strictEqual(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.strictEqual(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.strictEqual(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.strictEqual(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.strictEqual(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);
|
|
});
|
|
});
|
|
});
|