Files
blockly/core/utils/focusable_tree_traverser.ts
Ben Henning c426c6d820 fix: Short-circuit node lookups for missing IDs (#9174)
## The basics

- [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)

## The details
### Resolves

Fixes #9155

### Proposed Changes

In cases when an ID is missing for an element passed to `FocusableTreeTraverser.findFocusableNodeFor()`, always return `null`.

Additionally, the new short-circuit logic exposed that `Toolbox` actually wasn't being set up correctly (that is, its root element was not being configured with a valid ID). This has been fixed.

### Reason for Changes

These are cases when a valid node should never be matched (and it's technically possible to incorrectly match if an `IFocusableNode` is set up incorrectly and is providing a focusable element with an unset ID). This avoids the extra computation time of potentially calling deep into `WorkspaceSvg` and exploring all possible nodes for an ID that should never match.

Note that there is a weird quirk with `null` IDs actually being the string `"null"`. This is a side effect of how `setAttribute` and attributes in general work with HTML elements. There's nothing really that can be done here, so it's now considered invalid to also have an ID of string `"null"` just to ensure the `null` case is properly short-circuited.

Finally, the issue with toolbox being configured incorrectly was discovered with the introducing of a new hard failure in `FocusManager.registerTree()` when a tree with an invalid root element is registered. From testing there are no other such trees that need to be updated.

A new warning was also added if `focusNode()` is used on a node with an element that has an invalid ID. This isn't a hard failure to follow the convention of other invalid `focusNode()` situations. It's much more fragile for `focusNode()` to throw than `registerTree()` since the former generally happens much earlier in a page lifecycle, and is less prone to dynamic behaviors.

### Test Coverage

New tests were added to validate the various empty ID cases for `FocusableTreeTraverser.findFocusableNodeFor()`, and to validate the new error check for `FocusManager.registerTree()`.

### Documentation

No new documentation should be needed.

### Additional Information

Nothing to add.
2025-07-01 14:07:39 -07:00

127 lines
4.7 KiB
TypeScript

/**
* @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 rootNode = tree.getRootFocusableNode();
if (!rootNode.canBeFocused()) return null;
const root = rootNode.getFocusableElement();
if (
dom.hasClass(root, FocusableTreeTraverser.ACTIVE_CLASS_NAME) ||
dom.hasClass(root, FocusableTreeTraverser.PASSIVE_CSS_CLASS_NAME)
) {
// The root has focus.
return rootNode;
}
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, non-empty 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 {
// Note that the null check is due to Element.setAttribute() converting null
// to a string.
if (!element.id || element.id === 'null') return 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.
const rootNode = tree.getRootFocusableNode();
if (rootNode.canBeFocused() && element === rootNode.getFocusableElement()) {
return rootNode;
}
// 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;
}
}