Merge pull request #8814 from BenHenning/introduce-focus-system-implementation

feat: Introduce FocusManager implementation
This commit is contained in:
Ben Henning
2025-04-03 16:20:49 -07:00
committed by GitHub
9 changed files with 6249 additions and 18 deletions

View File

@@ -106,6 +106,11 @@ import {FlyoutItem} from './flyout_item.js';
import {FlyoutMetricsManager} from './flyout_metrics_manager.js';
import {FlyoutSeparator} from './flyout_separator.js';
import {VerticalFlyout} from './flyout_vertical.js';
import {
FocusManager,
ReturnEphemeralFocus,
getFocusManager,
} from './focus_manager.js';
import {CodeGenerator} from './generator.js';
import {Gesture} from './gesture.js';
import {Grid} from './grid.js';
@@ -519,6 +524,7 @@ export {
FlyoutItem,
FlyoutMetricsManager,
FlyoutSeparator,
FocusManager,
CodeGenerator as Generator,
Gesture,
Grid,
@@ -583,6 +589,7 @@ export {
Names,
Options,
RenderedConnection,
ReturnEphemeralFocus,
Scrollbar,
ScrollbarPair,
SeparatorFlyoutInflater,
@@ -604,6 +611,7 @@ export {
WorkspaceSvg,
ZoomControls,
config,
getFocusManager,
hasBubble,
icons,
inject,

View File

@@ -494,4 +494,13 @@ input[type=number] {
.blocklyDragging .blocklyIconGroup {
cursor: grabbing;
}
.blocklyActiveFocus {
outline-color: #2ae;
outline-width: 2px;
}
.blocklyPassiveFocus {
outline-color: #3fdfff;
outline-width: 1.5px;
}
`;

333
core/focus_manager.ts Normal file
View File

@@ -0,0 +1,333 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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';
import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
/**
* Type declaration for returning focus to FocusManager upon completing an
* ephemeral UI flow (such as a dialog).
*
* See FocusManager.takeEphemeralFocus for more details.
*/
export type ReturnEphemeralFocus = () => void;
/**
* A per-page singleton that manages Blockly focus across one or more
* IFocusableTrees, and bidirectionally synchronizes this focus with the DOM.
*
* Callers that wish to explicitly change input focus for select Blockly
* components on the page should use the focus functions in this manager.
*
* The manager is responsible for handling focus events from the DOM (which may
* may arise from users clicking on page elements) and ensuring that
* corresponding IFocusableNodes are clearly marked as actively/passively
* highlighted in the same way that this would be represented with calls to
* focusNode().
*/
export class FocusManager {
/**
* The CSS class assigned to IFocusableNode elements that presently have
* active DOM and Blockly focus.
*
* This should never be used directly. Instead, rely on FocusManager to ensure
* nodes have active focus (either automatically through DOM focus or manually
* through the various focus* methods provided by this class).
*
* It's recommended to not query using this class name, either. Instead, use
* FocusableTreeTraverser or IFocusableTree's methods to find a specific node.
*/
static readonly ACTIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyActiveFocus';
/**
* The CSS class assigned to IFocusableNode elements that presently have
* passive focus (that is, they were the most recent node in their relative
* tree to have active focus--see ACTIVE_FOCUS_NODE_CSS_CLASS_NAME--and will
* receive active focus again if their surrounding tree is requested to become
* focused, i.e. using focusTree below).
*
* See ACTIVE_FOCUS_NODE_CSS_CLASS_NAME for caveats and limitations around
* using this constant directly (generally it never should need to be used).
*/
static readonly PASSIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyPassiveFocus';
focusedNode: IFocusableNode | null = null;
registeredTrees: Array<IFocusableTree> = [];
private currentlyHoldsEphemeralFocus: boolean = false;
constructor(
addGlobalEventListener: (type: string, listener: EventListener) => void,
) {
// Register root document focus listeners for tracking when focus leaves all
// tracked focusable trees.
addGlobalEventListener('focusin', (event) => {
if (!(event instanceof FocusEvent)) return;
// The target that now has focus.
const activeElement = document.activeElement;
let newNode: IFocusableNode | null | undefined = null;
if (
activeElement instanceof HTMLElement ||
activeElement instanceof SVGElement
) {
// If the target losing focus maps to any tree, then it should be
// updated. Per the contract of findFocusableNodeFor only one tree
// should claim the element.
for (const tree of this.registeredTrees) {
newNode = FocusableTreeTraverser.findFocusableNodeFor(
activeElement,
tree,
);
if (newNode) break;
}
}
if (newNode) {
this.focusNode(newNode);
} else {
this.defocusCurrentFocusedNode();
}
});
}
/**
* Registers a new IFocusableTree for automatic focus management.
*
* If the tree currently has an element with DOM focus, it will not affect the
* internal state in this manager until the focus changes to a new,
* now-monitored element/node.
*
* This function throws if the provided tree is already currently registered
* in this manager. Use isRegistered to check in cases when it can't be
* certain whether the tree has been registered.
*/
registerTree(tree: IFocusableTree): void {
if (this.isRegistered(tree)) {
throw Error(`Attempted to re-register already registered tree: ${tree}.`);
}
this.registeredTrees.push(tree);
}
/**
* Returns whether the specified tree has already been registered in this
* manager using registerTree and hasn't yet been unregistered using
* unregisterTree.
*/
isRegistered(tree: IFocusableTree): boolean {
return this.registeredTrees.findIndex((reg) => reg === tree) !== -1;
}
/**
* Unregisters a IFocusableTree from automatic focus management.
*
* If the tree had a previous focused node, it will have its highlight
* removed. This function does NOT change DOM focus.
*
* This function throws if the provided tree is not currently registered in
* this manager.
*/
unregisterTree(tree: IFocusableTree): void {
if (!this.isRegistered(tree)) {
throw Error(`Attempted to unregister not registered tree: ${tree}.`);
}
const treeIndex = this.registeredTrees.findIndex((tree) => tree === tree);
this.registeredTrees.splice(treeIndex, 1);
const focusedNode = FocusableTreeTraverser.findFocusedNode(tree);
const root = tree.getRootFocusableNode();
if (focusedNode) this.removeHighlight(focusedNode);
if (this.focusedNode === focusedNode || this.focusedNode === root) {
this.focusedNode = null;
}
this.removeHighlight(root);
}
/**
* Returns the current IFocusableTree that has focus, or null if none
* currently do.
*
* Note also that if ephemeral focus is currently captured (e.g. using
* takeEphemeralFocus) then the returned tree here may not currently have DOM
* focus.
*/
getFocusedTree(): IFocusableTree | null {
return this.focusedNode?.getFocusableTree() ?? null;
}
/**
* Returns the current IFocusableNode with focus (which is always tied to a
* focused IFocusableTree), or null if there isn't one.
*
* Note that this function will maintain parity with
* IFocusableTree.getFocusedNode(). That is, if a tree itself has focus but
* none of its non-root children do, this will return null but
* getFocusedTree() will not.
*
* Note also that if ephemeral focus is currently captured (e.g. using
* takeEphemeralFocus) then the returned node here may not currently have DOM
* focus.
*/
getFocusedNode(): IFocusableNode | null {
return this.focusedNode;
}
/**
* Focuses the specific IFocusableTree. This either means restoring active
* focus to the tree's passively focused node, or focusing the tree's root
* node.
*
* Note that if the specified tree already has a focused node then this will
* not change any existing focus (unless that node has passive focus, then it
* will be restored to active focus).
*
* See getFocusedNode for details on how other nodes are affected.
*
* @param focusableTree The tree that should receive active
* focus.
*/
focusTree(focusableTree: IFocusableTree): void {
if (!this.isRegistered(focusableTree)) {
throw Error(`Attempted to focus unregistered tree: ${focusableTree}.`);
}
const currNode = FocusableTreeTraverser.findFocusedNode(focusableTree);
this.focusNode(currNode ?? focusableTree.getRootFocusableNode());
}
/**
* Focuses DOM input on the selected node, and marks it as actively focused.
*
* Any previously focused node will be updated to be passively highlighted (if
* it's in a different focusable tree) or blurred (if it's in the same one).
*
* @param focusableNode The node that should receive active
* focus.
*/
focusNode(focusableNode: IFocusableNode): void {
const nextTree = focusableNode.getFocusableTree();
if (!this.isRegistered(nextTree)) {
throw Error(`Attempted to focus unregistered node: ${focusableNode}.`);
}
const prevNode = this.focusedNode;
if (prevNode && prevNode.getFocusableTree() !== nextTree) {
this.setNodeToPassive(prevNode);
}
// If there's a focused node in the new node's tree, ensure it's reset.
const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree);
const nextTreeRoot = nextTree.getRootFocusableNode();
if (prevNodeNextTree) {
this.removeHighlight(prevNodeNextTree);
}
// For caution, ensure that the root is always reset since getFocusedNode()
// is expected to return null if the root was highlighted, if the root is
// not the node now being set to active.
if (nextTreeRoot !== focusableNode) {
this.removeHighlight(nextTreeRoot);
}
if (!this.currentlyHoldsEphemeralFocus) {
// Only change the actively focused node if ephemeral state isn't held.
this.setNodeToActive(focusableNode);
}
this.focusedNode = focusableNode;
}
/**
* Ephemerally captures focus for a selected element until the returned lambda
* is called. This is expected to be especially useful for ephemeral UI flows
* like dialogs.
*
* IMPORTANT: the returned lambda *must* be called, otherwise automatic focus
* will no longer work anywhere on the page. It is highly recommended to tie
* the lambda call to the closure of the corresponding UI so that if input is
* manually changed to an element outside of the ephemeral UI, the UI should
* close and automatic input restored. Note that this lambda must be called
* exactly once and that subsequent calls will throw an error.
*
* Note that the manager will continue to track DOM input signals even when
* ephemeral focus is active, but it won't actually change node state until
* the returned lambda is called. Additionally, only 1 ephemeral focus context
* can be active at any given time (attempting to activate more than one
* simultaneously will result in an error being thrown).
*/
takeEphemeralFocus(
focusableElement: HTMLElement | SVGElement,
): ReturnEphemeralFocus {
if (this.currentlyHoldsEphemeralFocus) {
throw Error(
`Attempted to take ephemeral focus when it's already held, ` +
`with new element: ${focusableElement}.`,
);
}
this.currentlyHoldsEphemeralFocus = true;
if (this.focusedNode) {
this.setNodeToPassive(this.focusedNode);
}
focusableElement.focus();
let hasFinishedEphemeralFocus = false;
return () => {
if (hasFinishedEphemeralFocus) {
throw Error(
`Attempted to finish ephemeral focus twice for element: ` +
`${focusableElement}.`,
);
}
hasFinishedEphemeralFocus = true;
this.currentlyHoldsEphemeralFocus = false;
if (this.focusedNode) {
this.setNodeToActive(this.focusedNode);
}
};
}
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, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
element.focus();
}
private setNodeToPassive(node: IFocusableNode): void {
const element = node.getFocusableElement();
dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
dom.addClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
}
private removeHighlight(node: IFocusableNode): void {
const element = node.getFocusableElement();
dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
}
}
let focusManager: FocusManager | null = null;
/**
* Returns the page-global FocusManager.
*
* The returned instance is guaranteed to not change across function calls, but
* may change across page loads.
*/
export function getFocusManager(): FocusManager {
if (!focusManager) {
focusManager = new FocusManager(document.addEventListener);
}
return focusManager;
}

View File

@@ -20,7 +20,10 @@ export interface IFocusableNode {
* - blocklyPassiveFocus
*
* The returned element must also have a valid ID specified, and unique to the
* element relative to its nearest IFocusableTree parent.
* element relative to its nearest IFocusableTree parent. It must also have a
* negative tabindex (since the focus manager itself will manage its tab index
* and a tab index must be present in order for the element to be focusable in
* the DOM).
*
* It's expected the return element will not change for the lifetime of the
* node.

View File

@@ -20,17 +20,14 @@ import type {IFocusableNode} from './i_focusable_node.js';
* page at any given time). The idea of passive focus is to provide context to
* users on where their focus will be restored upon navigating back to a
* previously focused tree.
*
* Note that if the tree's current focused node (passive or active) is needed,
* FocusableTreeTraverser.findFocusedNode can be used.
*
* Note that if specific nodes are needed to be retrieved for this tree, either
* use lookUpFocusableNode or FocusableTreeTraverser.findFocusableNodeFor.
*/
export interface IFocusableTree {
/**
* Returns the current node with focus in this tree, or null if none (or if
* the root has focus).
*
* Note that this will never return a node from a nested sub-tree as that tree
* should specifically be called in order to retrieve its focused node.
*/
getFocusedNode(): IFocusableNode | null;
/**
* Returns the top-level focusable node of the tree.
*
@@ -41,13 +38,24 @@ export interface IFocusableTree {
getRootFocusableNode(): IFocusableNode;
/**
* Returns the IFocusableNode corresponding to the select element, or null if
* the element does not have such a node.
* Returns all directly nested trees under this tree.
*
* The provided element must have a non-null ID that conforms to the contract
* mentioned in IFocusableNode.
* 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.
*/
findFocusableNodeFor(
element: HTMLElement | SVGElement,
): IFocusableNode | null;
getNestedTrees(): Array<IFocusableTree>;
/**
* 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;
}

View File

@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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
* tree traversals.
*/
export class FocusableTreeTraverser {
private static readonly ACTIVE_CLASS_NAME = 'blocklyActiveFocus';
private static readonly PASSIVE_CSS_CLASS_NAME = 'blocklyPassiveFocus';
private static readonly ACTIVE_FOCUS_NODE_CSS_SELECTOR = `.${FocusableTreeTraverser.ACTIVE_CLASS_NAME}`;
private static readonly PASSIVE_FOCUS_NODE_CSS_SELECTOR = `.${FocusableTreeTraverser.PASSIVE_CSS_CLASS_NAME}`;
/**
* Returns the current IFocusableNode that is styled (and thus represented) as
* having either passive or active focus, only considering HTML and SVG
* elements.
*
* This can match against the tree's root.
*
* Note that this will never return a node from a nested sub-tree as that tree
* should specifically be used to retrieve its focused node.
*
* @param tree The IFocusableTree in which to search for a focused node.
* @returns The IFocusableNode currently with focus, or null if none.
*/
static findFocusedNode(tree: IFocusableTree): IFocusableNode | null {
const root = tree.getRootFocusableNode().getFocusableElement();
if (
dom.hasClass(root, FocusableTreeTraverser.ACTIVE_CLASS_NAME) ||
dom.hasClass(root, FocusableTreeTraverser.PASSIVE_CSS_CLASS_NAME)
) {
// The root has focus.
return tree.getRootFocusableNode();
}
const activeEl = root.querySelector(this.ACTIVE_FOCUS_NODE_CSS_SELECTOR);
if (activeEl instanceof HTMLElement || activeEl instanceof SVGElement) {
const active = FocusableTreeTraverser.findFocusableNodeFor(
activeEl,
tree,
);
if (active) return active;
}
// At most there should be one passive indicator per tree (not considering
// subtrees).
const passiveEl = root.querySelector(this.PASSIVE_FOCUS_NODE_CSS_SELECTOR);
if (passiveEl instanceof HTMLElement || passiveEl instanceof SVGElement) {
const passive = FocusableTreeTraverser.findFocusableNodeFor(
passiveEl,
tree,
);
if (passive) return passive;
}
return null;
}
/**
* Returns the IFocusableNode corresponding to the specified HTML or SVG
* element iff it's the root element or a descendent of the root element of
* the specified IFocusableTree.
*
* If the element exists within the specified tree's DOM structure but does
* not directly correspond to a node, the nearest parent node (or the tree's
* root) will be returned to represent the provided element.
*
* 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
* IFocusableTree.lookUpFocusableNode.
*
* The provided element must have a non-null ID that conforms to the contract
* mentioned in IFocusableNode.
*
* @param element The HTML or SVG element being sought.
* @param tree The tree under which the provided element may be a descendant.
* @returns The matching IFocusableNode, or null if there is no match.
*/
static findFocusableNodeFor(
element: HTMLElement | SVGElement,
tree: IFocusableTree,
): IFocusableNode | null {
// First, match against subtrees.
const subTreeMatches = tree.getNestedTrees().map((tree) => {
return FocusableTreeTraverser.findFocusableNodeFor(element, tree);
});
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();
}
// 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) {
return FocusableTreeTraverser.findFocusableNodeFor(elementParent, tree);
}
// Otherwise, there's no matching node.
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,512 @@
/**
* @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;
}
}
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;
}
getNestedTrees() {
return this.nestedTrees;
}
lookUpFocusableNode(id) {
return this.idToNodeMap[id];
}
}
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);
});
});
});

View File

@@ -13,11 +13,152 @@
visibility: hidden;
width: 1000px;
}
.blocklyActiveFocus {
outline-color: #0f0;
outline-width: 2px;
}
.blocklyPassiveFocus {
outline-color: #00f;
outline-width: 1.5px;
}
div.blocklyActiveFocus {
color: #0f0;
}
div.blocklyPassiveFocus {
color: #00f;
}
g.blocklyActiveFocus {
fill: #0f0;
}
g.blocklyPassiveFocus {
fill: #00f;
}
</style>
<body>
<body tabindex="-1">
<div id="mocha"></div>
<div id="failureCount" style="display: none" tests_failed="unset"></div>
<div id="failureMessages" style="display: none"></div>
<div id="testFocusableTree1" tabindex="-1">
Focusable tree 1
<div id="testFocusableTree1.node1" style="margin-left: 1em" tabindex="-1">
Tree 1 node 1
<div
id="testFocusableTree1.node1.child1"
style="margin-left: 2em"
tabindex="-1">
Tree 1 node 1 child 1
<div
id="testFocusableTree1.node1.child1.unregisteredChild1"
style="margin-left: 3em"
tabindex="-1">
Tree 1 node 1 child 1 child 1 (unregistered)
</div>
</div>
</div>
<div id="testFocusableTree1.node2" style="margin-left: 1em" tabindex="-1">
Tree 1 node 2
<div
id="testFocusableTree1.node2.unregisteredChild1"
style="margin-left: 2em"
tabindex="-1">
Tree 1 node 2 child 2 (unregistered)
</div>
</div>
<div
id="testFocusableTree1.unregisteredChild1"
style="margin-left: 1em"
tabindex="-1">
Tree 1 child 1 (unregistered)
</div>
</div>
<div id="testFocusableTree2" tabindex="-1">
Focusable tree 2
<div id="testFocusableTree2.node1" style="margin-left: 1em" tabindex="-1">
Tree 2 node 1
<div
id="testFocusableNestedTree4"
style="margin-left: 2em"
tabindex="-1">
Nested tree 4
<div
id="testFocusableNestedTree4.node1"
style="margin-left: 3em"
tabindex="-1">
Tree 4 node 1 (nested)
<div
id="testFocusableNestedTree4.node1.unregisteredChild1"
style="margin-left: 4em"
tabindex="-1">
Tree 4 node 1 child 1 (unregistered)
</div>
</div>
</div>
</div>
<div id="testFocusableNestedTree5" style="margin-left: 1em" tabindex="-1">
Nested tree 5
<div
id="testFocusableNestedTree5.node1"
style="margin-left: 2em"
tabindex="-1">
Tree 5 node 1 (nested)
</div>
</div>
</div>
<div id="testUnregisteredFocusableTree3" tabindex="-1">
Unregistered tree 3
<div
id="testUnregisteredFocusableTree3.node1"
style="margin-left: 1em"
tabindex="-1">
Tree 3 node 1 (unregistered)
</div>
</div>
<div id="testUnfocusableElement">Unfocusable element</div>
<div id="nonTreeElementForEphemeralFocus" tabindex="-1" />
<svg width="250" height="250">
<g id="testFocusableGroup1" tabindex="-1">
<g id="testFocusableGroup1.node1" tabindex="-1">
<rect x="0" y="0" width="250" height="30" fill="grey" />
<text x="10" y="20" class="svgText">Group 1 node 1</text>
<g id="testFocusableGroup1.node1.child1" tabindex="-1">
<rect x="0" y="30" width="250" height="30" fill="lightgrey" />
<text x="10" y="50" class="svgText">Tree 1 node 1 child 1</text>
</g>
</g>
<g id="testFocusableGroup1.node2" tabindex="-1">
<rect x="0" y="60" width="250" height="30" fill="grey" />
<text x="10" y="80" class="svgText">Group 1 node 2</text>
<g id="testFocusableGroup1.node2.unregisteredChild1" tabindex="-1">
<rect x="0" y="90" width="250" height="30" fill="lightgrey" />
<text x="10" y="110" class="svgText">
Tree 1 node 2 child 2 (unregistered)
</text>
</g>
</g>
</g>
<g id="testFocusableGroup2" tabindex="-1">
<g id="testFocusableGroup2.node1" tabindex="-1">
<rect x="0" y="120" width="250" height="30" fill="grey" />
<text x="10" y="140" class="svgText">Group 2 node 1</text>
</g>
<g id="testFocusableNestedGroup4" tabindex="-1">
<g id="testFocusableNestedGroup4.node1" tabindex="-1">
<rect x="0" y="150" width="250" height="30" fill="lightgrey" />
<text x="10" y="170" class="svgText">Group 4 node 1 (nested)</text>
</g>
</g>
</g>
<g id="testUnregisteredFocusableGroup3" tabindex="-1">
<g id="testUnregisteredFocusableGroup3.node1" tabindex="-1">
<rect x="0" y="180" width="250" height="30" fill="grey" />
<text x="10" y="200" class="svgText">
Tree 3 node 1 (unregistered)
</text>
</g>
</g>
<g id="nonTreeGroupForEphemeralFocus" tabindex="-1"></g>
</svg>
<!-- Load mocha et al. before Blockly and the test modules so that
we can safely import the test modules that make calls
to (e.g.) suite() at the top level. -->
@@ -90,6 +231,8 @@
import './field_textinput_test.js';
import './field_variable_test.js';
import './flyout_test.js';
import './focus_manager_test.js';
import './focusable_tree_traverser_test.js';
import './generator_test.js';
import './gesture_test.js';
import './icon_test.js';