Files
blockly/core/focus_manager.ts
Ben Henning fdeaa7692b feat: Update line cursor to use focus manager (#8941)
## The basics

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

## The details
### Resolves

Fixes #8940
Fixes #8954
Fixes #8955

### Proposed Changes

This updates `LineCursor` to use `FocusManager` rather than selection (principally) as the source of truth.

### Reason for Changes

Ensuring that keyboard navigation works correctly with eventual screen reader support requires ensuring that ever navigated component is focused, and this is primarily what `FocusManager` has been designed to do. Since these nodes are already focused, `FocusManager` can be used as the primary source of truth for determining where the user currently has navigated, and where to go next.

Previously, `LineCursor` relied on selection for this purpose, but selection is now automatically updated (for blocks) using focus-controlled `focus` and `blur` callbacks. Note that the cursor will still fall back to synchronizing with selection state, though this will be removed once the remaining work to eliminate `MarkerSvg` has concluded (which requires further consideration on the keyboard navigation side viz-a-viz styling and CSS decisions) and once mouse clicks are synchronized with focus management.

Note that the changes in this PR are closely tied to https://github.com/google/blockly-keyboard-experimentation/pull/482 as both are necessary in order for the keyboard navigation plugin to correctly work with `FocusManager`.

Some other noteworthy changes:
- Some special handling exists for flyouts to handle navigating across stacks (per the current cursor design).
- `FocusableTreeTraverser` is needed by the keyboard navigation plugin (in https://github.com/google/blockly-keyboard-experimentation/pull/482) so it's now being exported.
- `FocusManager` had one bug that's now patched and tested in this PR: it didn't handle the case of the browser completely forcing focus loss. It would continue to maintain active focus even though no tracked elements now hold focus. One such case is the element being deleted, but there are other cases where this can happen (such as with dialog prompts).
- `FocusManager` had some issues from #8909 wherein it would overeagerly call tree focus callbacks and slightly mismanage the passive node. Since tests haven't yet been added for these lifecycle callbacks, these cases weren't originally caught (per #8910).
- `FocusManager` was updated to move the tracked manager into a static function so that it can be replaced in tests. This was done to facilitate changes to setup_teardown.js to ensure that a unique `FocusManager` exists _per-test_. It's possible for DOM focus state to still bleed across tests, but `FocusManager` largely guarantees eventual consistency. This change prevents a class of focus errors from being possible when running tests.
- A number of cursor tests needed to be updated to ensure that a connections are properly rendered (as this is a requirement for focusable nodes, and cursor is now focusing nodes). One test for output connections was changed to use an input connection, instead, since output connections can no longer be navigated to (and aren't rendered, thus are not focusable). It's possible this will need to be changed in the future if we decide to reintroduce support for output connections in cursor, but it seems like a reasonable stopgap. Huge thanks to @rachel-fenichel for helping investigate and providing an alternative for the output connection test.

**Current gaps** to be fixed after this PR is merged:
- The flyout automatically closes when creating a variable with with keyboard or mouse (I think this is only for the keyboard navigation plugin). I believe this is a regression from previous behavior due to how the navigation plugin is managing state. It would know the flyout should be open and thus ensure it stays open even when things like dialog prompts try to close it with a blur event. However, the new implementation in https://github.com/google/blockly-keyboard-experimentation/pull/482 complicates this since state is now inferred from `FocusManager`, and the flyout _losing_ focus will force it closed. There was a fix introduced in this PR to fix it for keyboard navigation, but fails for clicks because the flyout never receives focus when the create variable button is clicked. It also caused the advanced compilation tests to fail due to a subtle circular dependency from importing `WorkspaceSvg` directly rather than its type.
- The flyout, while it stays open, does not automatically update past the first variable being created without closing and reopening it. I'm actually not at all sure why this particular behavior has regressed.

### Test Coverage

No new non-`FocusManager` tests have been added. It's certainly possible to add unit tests for the focusable configurations being introduced in this PR, but it may not be highly beneficial. It's largely assumed that the individual implementations should work due to a highly tested FocusManager, and it may be the case that the interactions of the components working together is far more important to verify (that is, the end user flows). The latter is planned to be tackled as part of #8915.

Some new `FocusManager` tests were added, but more are still needed and this is tracked as part of #8910.

### Documentation

No new documentation should be needed for these changes.

### Additional Information

This includes changes that have been pulled from #8875.
2025-05-01 22:18:22 -07:00

480 lines
18 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';
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;
private lockFocusStateChanges: boolean = false;
constructor(
addGlobalEventListener: (type: string, listener: EventListener) => void,
) {
// Note that 'element' here is the element *gaining* focus.
const maybeFocus = (element: Element | EventTarget | null) => {
let newNode: IFocusableNode | null | undefined = null;
if (element instanceof HTMLElement || element instanceof SVGElement) {
// If the target losing or gaining focus maps to any tree, then it
// should be updated. Per the contract of findFocusableNodeFor only one
// tree should claim the element, so the search can be exited early.
for (const tree of this.registeredTrees) {
newNode = FocusableTreeTraverser.findFocusableNodeFor(element, tree);
if (newNode) break;
}
}
if (newNode) {
const newTree = newNode.getFocusableTree();
const oldTree = this.focusedNode?.getFocusableTree();
if (newNode === newTree.getRootFocusableNode() && newTree !== oldTree) {
// If the root of the tree is the one taking focus (such as due to
// being tabbed), try to focus the whole tree explicitly to ensure the
// correct node re-receives focus.
this.focusTree(newTree);
} else {
this.focusNode(newNode);
}
} else {
this.defocusCurrentFocusedNode();
}
};
// Register root document focus listeners for tracking when focus leaves all
// tracked focusable trees. Note that focusin and focusout can be somewhat
// overlapping in the information that they provide. This is fine because
// they both aim to check for focus changes on the element gaining or having
// received focus, and maybeFocus should behave relatively deterministic.
addGlobalEventListener('focusin', (event) => {
if (!(event instanceof FocusEvent)) return;
// When something receives focus, always use the current active element as
// the source of truth.
maybeFocus(document.activeElement);
});
addGlobalEventListener('focusout', (event) => {
if (!(event instanceof FocusEvent)) return;
// When something loses focus, it seems that document.activeElement may
// not necessarily be correct. Instead, use relatedTarget.
maybeFocus(event.relatedTarget);
});
}
/**
* 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 {
this.ensureManagerIsUnlocked();
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 {
this.ensureManagerIsUnlocked();
if (!this.isRegistered(tree)) {
throw Error(`Attempted to unregister not registered tree: ${tree}.`);
}
const treeIndex = this.registeredTrees.findIndex((reg) => reg === 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 {
this.ensureManagerIsUnlocked();
if (!this.isRegistered(focusableTree)) {
throw Error(`Attempted to focus unregistered tree: ${focusableTree}.`);
}
const currNode = FocusableTreeTraverser.findFocusedNode(focusableTree);
const nodeToRestore = focusableTree.getRestoredFocusableNode(currNode);
const rootFallback = focusableTree.getRootFocusableNode();
this.focusNode(nodeToRestore ?? currNode ?? rootFallback);
}
/**
* 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 {
this.ensureManagerIsUnlocked();
if (this.focusedNode === focusableNode) return; // State is unchanged.
const nextTree = focusableNode.getFocusableTree();
if (!this.isRegistered(nextTree)) {
throw Error(`Attempted to focus unregistered node: ${focusableNode}.`);
}
// Safety check for ensuring focusNode() doesn't get called for a node that
// isn't actually hooked up to its parent tree correctly (since this can
// cause weird inconsistencies).
const matchedNode = FocusableTreeTraverser.findFocusableNodeFor(
focusableNode.getFocusableElement(),
nextTree,
);
if (matchedNode !== focusableNode) {
throw Error(
`Attempting to focus node which isn't recognized by its parent tree: ` +
`${focusableNode}.`,
);
}
const prevNode = this.focusedNode;
const prevTree = prevNode?.getFocusableTree();
if (prevNode) {
this.passivelyFocusNode(prevNode, nextTree);
}
// 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.activelyFocusNode(focusableNode, prevTree ?? null);
}
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 {
this.ensureManagerIsUnlocked();
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.passivelyFocusNode(this.focusedNode, null);
}
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.activelyFocusNode(this.focusedNode, null);
}
};
}
/**
* Ensures that the manager is currently allowing operations that change its
* internal focus state (such as via focusNode()).
*
* If the manager is currently not allowing state changes, an exception is
* thrown.
*/
private ensureManagerIsUnlocked(): void {
if (this.lockFocusStateChanges) {
throw Error(
'FocusManager state changes cannot happen in a tree/node focus/blur ' +
'callback.',
);
}
}
/**
* Defocuses the current actively focused node tracked by the manager, iff
* there's a node being tracked and the manager doesn't have ephemeral focus.
*/
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.passivelyFocusNode(this.focusedNode, null);
this.focusedNode = null;
}
}
/**
* Marks the specified node as actively focused, also calling related lifecycle
* callback methods for both the node and its parent tree. This ensures that
* the node is properly styled to indicate its active focus.
*
* This does not change the manager's currently tracked node, nor does it
* change any other nodes.
*
* @param node The node to be actively focused.
* @param prevTree The tree of the previously actively focused node, or null
* if there wasn't a previously actively focused node.
*/
private activelyFocusNode(
node: IFocusableNode,
prevTree: IFocusableTree | null,
): void {
// Note that order matters here. Focus callbacks are allowed to change
// element visibility which can influence focusability, including for a
// node's focusable element (which *is* allowed to be invisible until the
// node needs to be focused).
this.lockFocusStateChanges = true;
if (node.getFocusableTree() !== prevTree) {
node.getFocusableTree().onTreeFocus(node, prevTree);
}
node.onNodeFocus();
this.lockFocusStateChanges = false;
this.setNodeToVisualActiveFocus(node);
node.getFocusableElement().focus();
}
/**
* Marks the specified node as passively focused, also calling related
* lifecycle callback methods for both the node and its parent tree. This
* ensures that the node is properly styled to indicate its passive focus.
*
* This does not change the manager's currently tracked node, nor does it
* change any other nodes.
*
* @param node The node to be passively focused.
* @param nextTree The tree of the node receiving active focus, or null if no
* node will be actively focused.
*/
private passivelyFocusNode(
node: IFocusableNode,
nextTree: IFocusableTree | null,
): void {
this.lockFocusStateChanges = true;
if (node.getFocusableTree() !== nextTree) {
node.getFocusableTree().onTreeBlur(nextTree);
}
node.onNodeBlur();
this.lockFocusStateChanges = false;
if (node.getFocusableTree() !== nextTree) {
this.setNodeToVisualPassiveFocus(node);
}
}
/**
* Updates the node's styling to indicate that it should have an active focus
* indicator.
*
* @param node The node to be styled for active focus.
*/
private setNodeToVisualActiveFocus(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);
}
/**
* Updates the node's styling to indicate that it should have a passive focus
* indicator.
*
* @param node The node to be styled for passive focus.
*/
private setNodeToVisualPassiveFocus(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);
}
/**
* Removes any active/passive indicators for the specified node.
*
* @param node The node which should have neither passive nor active focus
* indication.
*/
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);
}
private static 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.
*/
static getFocusManager(): FocusManager {
if (!FocusManager.focusManager) {
FocusManager.focusManager = new FocusManager(document.addEventListener);
}
return FocusManager.focusManager;
}
}
/** Convenience function for FocusManager.getFocusManager. */
export function getFocusManager(): FocusManager {
return FocusManager.getFocusManager();
}