feat!: Introduce new focus tree/node functions.

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.
This commit is contained in:
Ben Henning
2025-04-21 20:37:26 +00:00
parent 9d127698d6
commit 0772a29824
5 changed files with 248 additions and 30 deletions

View File

@@ -25,8 +25,14 @@ export interface IFocusableNode {
* 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.
* The returned element must be visible if the node is ever focused via
* FocusManager.focusNode() or FocusManager.focusTree(). It's allowed for an
* element to be hidden until onNodeFocus() is called, or become hidden with a
* call to onNodeBlur().
*
* It's expected the actual returned element will not change for the lifetime
* of the node (that is, its properties can change but a new element should
* never be returned.)
*/
getFocusableElement(): HTMLElement | SVGElement;
@@ -36,4 +42,38 @@ export interface IFocusableNode {
* belongs.
*/
getFocusableTree(): IFocusableTree;
/**
* Called when this node receives active focus.
*
* Note that it's fine for implementations to change visibility modifiers, but
* they should avoid the following:
* - Creating or removing DOM elements (including via the renderer or drawer).
* - Affecting focus via DOM focus() calls or the FocusManager.
*/
onNodeFocus(): void;
/**
* Called when this node loses active focus. It may still have passive focus.
*
* This has the same implementation restrictions as onNodeFocus().
*/
onNodeBlur(): void;
}
/**
* Determines whether the provided object fulfills the contract of
* IFocusableNode.
*
* @param object The object to test.
* @returns Whether the provided object can be used as an IFocusableNode.
*/
export function isFocusableNode(object: any | null): object is IFocusableNode {
return (
object &&
'getFocusableElement' in object &&
'getFocusableTree' in object &&
'onNodeFocus' in object &&
'onNodeBlur' in object
);
}

View File

@@ -37,6 +37,34 @@ export interface IFocusableTree {
*/
getRootFocusableNode(): IFocusableNode;
/**
* Returns the IFocusableNode of this tree that should receive active focus
* when the tree itself has focused returned to it.
*
* There are some very important notes to consider about a tree's focus
* lifecycle when implementing a version of this method that doesn't return
* null:
* 1. A null previousNode does not guarantee first-time focus state as nodes
* can be deleted.
* 2. This method is only used when the tree itself is focused, either through
* tab navigation or via FocusManager.focusTree(). In many cases, the
* previously focused node will be directly focused instead which will
* bypass this method.
* 3. The default behavior (i.e. returning null here) involves either
* restoring the previous node (previousNode) or focusing the tree's root.
*
* This method is largely intended to provide tree implementations with the
* means of specifying a better default node than their root.
*
* @param previousNode The node that previously held passive focus for this
* tree, or null if the tree hasn't yet been focused.
* @returns The IFocusableNode that should now receive focus, or null if
* default behavior should be used, instead.
*/
getRestoredFocusableNode(
previousNode: IFocusableNode | null,
): IFocusableNode | null;
/**
* Returns all directly nested trees under this tree.
*
@@ -58,4 +86,55 @@ export interface IFocusableTree {
* @param id The ID of the node's focusable HTMLElement or SVGElement.
*/
lookUpFocusableNode(id: string): IFocusableNode | null;
/**
* Called when a node of this tree has received active focus.
*
* Note that a null previousTree does not necessarily indicate that this is
* the first time Blockly is receiving focus. In fact, few assumptions can be
* made about previous focus state as a previous null tree simply indicates
* that Blockly did not hold active focus prior to this tree becoming focused
* (which can happen due to focus exiting the Blockly injection div, or for
* other cases like ephemeral focus).
*
* See IFocusableNode.onNodeFocus() as implementations have the same
* restrictions as with that method.
*
* @param node The node receiving active focus.
* @param previousTree The previous tree that held active focus, or null if
* none.
*/
onTreeFocus(node: IFocusableNode, previousTree: IFocusableTree | null): void;
/**
* Called when the previously actively focused node of this tree is now
* passively focused and there is no other active node of this tree taking its
* place.
*
* This has the same implementation restrictions and considerations as
* onTreeFocus().
*
* @param nextTree The next tree receiving active focus, or null if none (such
* as in the case that Blockly is entirely losing DOM focus).
*/
onTreeBlur(nextTree: IFocusableTree | null): void;
}
/**
* Determines whether the provided object fulfills the contract of
* IFocusableTree.
*
* @param object The object to test.
* @returns Whether the provided object can be used as an IFocusableTree.
*/
export function isFocusableTree(object: any | null): object is IFocusableTree {
return (
object &&
'getRootFocusableNode' in object &&
'getRestoredFocusableNode' in object &&
'getNestedTrees' in object &&
'lookUpFocusableNode' in object &&
'onTreeFocus' in object &&
'onTreeBlur' in object
);
}