feat: add FocusManager

This is the bulk of the work for introducing the central logical unit
for managing and sychronizing focus as a first-class Blockly concept
with that of DOM focus.

There's a lot to do yet, including:
- Ensuring clicks within Blockly's scope correctly sync back to focus
  changes.
- Adding support for, and testing, cases when focus is lost from all
  registered trees.
- Testing nested tree propagation.
- Testing the traverser utility class.
- Adding implementations for IFocusableTree and IFocusableNode
  throughout Blockly.
This commit is contained in:
Ben Henning
2025-03-21 00:33:51 +00:00
parent 8c25c1f8ed
commit d9beacddb4
8 changed files with 4389 additions and 1 deletions

View File

@@ -106,6 +106,7 @@ 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, getFocusManager} from './focus_manager.js';
import {CodeGenerator} from './generator.js';
import {Gesture} from './gesture.js';
import {Grid} from './grid.js';
@@ -521,6 +522,7 @@ export {
FlyoutItem,
FlyoutMetricsManager,
FlyoutSeparator,
FocusManager,
CodeGenerator as Generator,
Gesture,
Grid,
@@ -607,6 +609,7 @@ export {
WorkspaceSvg,
ZoomControls,
config,
getFocusManager,
hasBubble,
icons,
inject,

View File

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

295
core/focus_manager.ts Normal file
View File

@@ -0,0 +1,295 @@
/**
* @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';
/**
* 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 {
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 = 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.
const matchingNodes = this.registeredTrees.map((tree) =>
tree.findFocusableNodeFor(activeElement),
);
newNode = matchingNodes.find((node) => !!node) ?? null;
}
if (newNode) {
this.focusNode(newNode);
} else {
// TODO: Set previous to passive if all trees are losing active focus.
}
});
}
/**
* 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 = tree.getFocusedNode();
const root = tree.getRootFocusableNode();
if (focusedNode != null) 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}.`);
}
this.focusNode(
focusableTree.getFocusedNode() ?? 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 curTree = focusableNode.getFocusableTree();
if (!this.isRegistered(curTree)) {
throw Error(`Attempted to focus unregistered node: ${focusableNode}.`);
}
const prevNode = this.focusedNode;
if (prevNode && prevNode.getFocusableTree() !== curTree) {
this.setNodeToPassive(prevNode);
}
// If there's a focused node in the new node's tree, ensure it's reset.
const prevNodeCurTree = curTree.getFocusedNode();
const curTreeRoot = curTree.getRootFocusableNode();
if (prevNodeCurTree) {
this.removeHighlight(prevNodeCurTree);
}
// 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 (curTreeRoot !== focusableNode) {
this.removeHighlight(curTreeRoot);
}
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 setNodeToActive(node: IFocusableNode): void {
const element = node.getFocusableElement();
dom.addClass(element, 'blocklyActiveFocus');
dom.removeClass(element, 'blocklyPassiveFocus');
element.focus();
}
private setNodeToPassive(node: IFocusableNode): void {
const element = node.getFocusableElement();
dom.removeClass(element, 'blocklyActiveFocus');
dom.addClass(element, 'blocklyPassiveFocus');
}
private removeHighlight(node: IFocusableNode): void {
const element = node.getFocusableElement();
dom.removeClass(element, 'blocklyActiveFocus');
dom.removeClass(element, 'blocklyPassiveFocus');
}
}
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

@@ -46,6 +46,8 @@ 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.
*/
findFocusableNodeFor(
element: HTMLElement | SVGElement,

View File

@@ -0,0 +1,84 @@
/**
* @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';
/**
* A helper utility for IFocusableTree implementations to aid with common
* tree traversals.
*/
export class FocusableTreeTraverser {
/**
* Returns the current IFocusableNode that either has the CSS class
* 'blocklyActiveFocus' or 'blocklyPassiveFocus', only considering HTML and
* SVG elements.
*
* This can match against the tree's root.
*
* @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();
const activeElem = root.querySelector('.blocklyActiveFocus');
let active: IFocusableNode | null = null;
if (activeElem instanceof HTMLElement || activeElem instanceof SVGElement) {
active = tree.findFocusableNodeFor(activeElem);
}
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;
}
/**
* 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 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.
*
* @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 {
if (element === tree.getRootFocusableNode().getFocusableElement()) {
return tree.getRootFocusableNode();
}
const matchedChildNode = findChildById(element.id);
const elementParent = element.parentElement;
if (!matchedChildNode && elementParent) {
// Recurse up to find the nearest tree/node.
return FocusableTreeTraverser.findFocusableNodeFor(
elementParent,
tree,
findChildById,
);
}
return matchedChildNode;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,11 +13,82 @@
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>
<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">
<div id="testFocusableTree1.node1" tabindex="-1">
Tree 1 node 1
<div id="testFocusableTree1.node1.child1" tabindex="-1">Tree 1 node 1 child 1</div>
</div>
<div id="testFocusableTree1.node2" tabindex="-1">
Tree 1 node 2
<div id="testFocusableTree1.node2.unregisteredChild1" tabindex="-1">Tree 1 node 2 child 2 (unregistered)</div>
</div>
</div>
<div id="testFocusableTree2" tabindex="-1">
<div id="testFocusableTree2.node1" tabindex="-1">Tree 2 node 1</div>
</div>
<div id="testUnregisteredFocusableTree3" tabindex="-1">
<div id="testUnregisteredFocusableTree3.node1" tabindex="-1">Tree 3 node 1 (unregistered)</div>
</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>
<g id="testUnregisteredFocusableGroup3" tabindex="-1">
<g id="testUnregisteredFocusableGroup3.node1" tabindex="-1">
<rect x="0" y="150" width="250" height="30" fill="grey" />
<text x="10" y="170" 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 +161,8 @@
import './field_textinput_test.js';
import './field_variable_test.js';
import './flyout_test.js';
import './focus_manager_test.js';
// import './test_event_reduction.js';
import './generator_test.js';
import './gesture_test.js';
import './icon_test.js';