Skip to content

Commit 0772a29

Browse files
committed
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 RaspberryPiFoundation#8875 and are required for later PRs that will introduce IFocusableNode and IFocusableTree implementations.
1 parent 9d12769 commit 0772a29

File tree

5 files changed

+247
-29
lines changed

5 files changed

+247
-29
lines changed

core/focus_manager.ts

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export class FocusManager {
6060
registeredTrees: Array<IFocusableTree> = [];
6161

6262
private currentlyHoldsEphemeralFocus: boolean = false;
63+
private lockFocusStateChanges: boolean = false;
6364

6465
constructor(
6566
addGlobalEventListener: (type: string, listener: EventListener) => void,
@@ -89,7 +90,16 @@ export class FocusManager {
8990
}
9091

9192
if (newNode) {
92-
this.focusNode(newNode);
93+
const newTree = newNode.getFocusableTree();
94+
const oldTree = this.focusedNode?.getFocusableTree();
95+
if (newNode === newTree.getRootFocusableNode() && newTree !== oldTree) {
96+
// If the root of the tree is the one taking focus (such as due to
97+
// being tabbed), try to focus the whole tree explicitly to ensure the
98+
// correct node re-receives focus.
99+
this.focusTree(newTree);
100+
} else {
101+
this.focusNode(newNode);
102+
}
93103
} else {
94104
this.defocusCurrentFocusedNode();
95105
}
@@ -108,6 +118,7 @@ export class FocusManager {
108118
* certain whether the tree has been registered.
109119
*/
110120
registerTree(tree: IFocusableTree): void {
121+
this.ensureManagerIsUnlocked();
111122
if (this.isRegistered(tree)) {
112123
throw Error(`Attempted to re-register already registered tree: ${tree}.`);
113124
}
@@ -133,6 +144,7 @@ export class FocusManager {
133144
* this manager.
134145
*/
135146
unregisterTree(tree: IFocusableTree): void {
147+
this.ensureManagerIsUnlocked();
136148
if (!this.isRegistered(tree)) {
137149
throw Error(`Attempted to unregister not registered tree: ${tree}.`);
138150
}
@@ -192,11 +204,14 @@ export class FocusManager {
192204
* focus.
193205
*/
194206
focusTree(focusableTree: IFocusableTree): void {
207+
this.ensureManagerIsUnlocked();
195208
if (!this.isRegistered(focusableTree)) {
196209
throw Error(`Attempted to focus unregistered tree: ${focusableTree}.`);
197210
}
198211
const currNode = FocusableTreeTraverser.findFocusedNode(focusableTree);
199-
this.focusNode(currNode ?? focusableTree.getRootFocusableNode());
212+
const nodeToRestore = focusableTree.getRestoredFocusableNode(currNode);
213+
const rootFallback = focusableTree.getRootFocusableNode();
214+
this.focusNode(nodeToRestore ?? currNode ?? rootFallback);
200215
}
201216

202217
/**
@@ -205,18 +220,37 @@ export class FocusManager {
205220
* Any previously focused node will be updated to be passively highlighted (if
206221
* it's in a different focusable tree) or blurred (if it's in the same one).
207222
*
208-
* @param focusableNode The node that should receive active
209-
* focus.
223+
* @param focusableNode The node that should receive active focus.
210224
*/
211225
focusNode(focusableNode: IFocusableNode): void {
226+
this.ensureManagerIsUnlocked();
227+
if (this.focusedNode == focusableNode) return; // State is unchanged.
228+
212229
const nextTree = focusableNode.getFocusableTree();
213230
if (!this.isRegistered(nextTree)) {
214231
throw Error(`Attempted to focus unregistered node: ${focusableNode}.`);
215232
}
233+
234+
// Safety check for ensuring focusNode() doesn't get called for a node that
235+
// isn't actually hooked up to its parent tree correctly (since this can
236+
// cause weird inconsistencies).
237+
const matchedNode = FocusableTreeTraverser.findFocusableNodeFor(
238+
focusableNode.getFocusableElement(),
239+
nextTree,
240+
);
241+
if (matchedNode !== focusableNode) {
242+
throw Error(
243+
`Attempting to focus node which isn't recognized by its parent tree: ` +
244+
`${focusableNode}.`,
245+
);
246+
}
247+
216248
const prevNode = this.focusedNode;
217-
if (prevNode && prevNode.getFocusableTree() !== nextTree) {
218-
this.setNodeToPassive(prevNode);
249+
const prevTree = prevNode?.getFocusableTree();
250+
if (prevNode && prevTree !== nextTree) {
251+
this.passivelyFocusNode(prevNode, nextTree);
219252
}
253+
220254
// If there's a focused node in the new node's tree, ensure it's reset.
221255
const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree);
222256
const nextTreeRoot = nextTree.getRootFocusableNode();
@@ -229,9 +263,10 @@ export class FocusManager {
229263
if (nextTreeRoot !== focusableNode) {
230264
this.removeHighlight(nextTreeRoot);
231265
}
266+
232267
if (!this.currentlyHoldsEphemeralFocus) {
233268
// Only change the actively focused node if ephemeral state isn't held.
234-
this.setNodeToActive(focusableNode);
269+
this.activelyFocusNode(focusableNode, prevTree ?? null);
235270
}
236271
this.focusedNode = focusableNode;
237272
}
@@ -257,6 +292,7 @@ export class FocusManager {
257292
takeEphemeralFocus(
258293
focusableElement: HTMLElement | SVGElement,
259294
): ReturnEphemeralFocus {
295+
this.ensureManagerIsUnlocked();
260296
if (this.currentlyHoldsEphemeralFocus) {
261297
throw Error(
262298
`Attempted to take ephemeral focus when it's already held, ` +
@@ -266,7 +302,7 @@ export class FocusManager {
266302
this.currentlyHoldsEphemeralFocus = true;
267303

268304
if (this.focusedNode) {
269-
this.setNodeToPassive(this.focusedNode);
305+
this.passivelyFocusNode(this.focusedNode, null);
270306
}
271307
focusableElement.focus();
272308

@@ -282,29 +318,66 @@ export class FocusManager {
282318
this.currentlyHoldsEphemeralFocus = false;
283319

284320
if (this.focusedNode) {
285-
this.setNodeToActive(this.focusedNode);
321+
this.activelyFocusNode(this.focusedNode, null);
286322
}
287323
};
288324
}
289325

326+
private ensureManagerIsUnlocked(): void {
327+
if (this.lockFocusStateChanges) {
328+
throw Error(
329+
'FocusManager state changes cannot happen in a tree/node focus/blur ' +
330+
'callback.',
331+
);
332+
}
333+
}
334+
290335
private defocusCurrentFocusedNode(): void {
291336
// The current node will likely be defocused while ephemeral focus is held,
292337
// but internal manager state shouldn't change since the node should be
293338
// restored upon exiting ephemeral focus mode.
294339
if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) {
295-
this.setNodeToPassive(this.focusedNode);
340+
this.passivelyFocusNode(this.focusedNode, null);
296341
this.focusedNode = null;
297342
}
298343
}
299344

300-
private setNodeToActive(node: IFocusableNode): void {
345+
private activelyFocusNode(
346+
node: IFocusableNode,
347+
prevTree: IFocusableTree | null,
348+
): void {
349+
// Note that order matters here. Focus callbacks are allowed to change
350+
// element visibility which can influence focusability, including for a
351+
// node's focusable element (which *is* allowed to be invisible until the
352+
// node needs to be focused).
353+
this.lockFocusStateChanges = true;
354+
node.getFocusableTree().onTreeFocus(node, prevTree);
355+
node.onNodeFocus();
356+
this.lockFocusStateChanges = false;
357+
358+
this.setNodeToVisualActiveFocus(node);
359+
node.getFocusableElement().focus();
360+
}
361+
362+
private passivelyFocusNode(
363+
node: IFocusableNode,
364+
nextTree: IFocusableTree | null,
365+
): void {
366+
this.lockFocusStateChanges = true;
367+
node.getFocusableTree().onTreeBlur(nextTree);
368+
node.onNodeBlur();
369+
this.lockFocusStateChanges = false;
370+
371+
this.setNodeToVisualPassiveFocus(node);
372+
}
373+
374+
private setNodeToVisualActiveFocus(node: IFocusableNode): void {
301375
const element = node.getFocusableElement();
302376
dom.addClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
303377
dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
304-
element.focus();
305378
}
306379

307-
private setNodeToPassive(node: IFocusableNode): void {
380+
private setNodeToVisualPassiveFocus(node: IFocusableNode): void {
308381
const element = node.getFocusableElement();
309382
dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
310383
dom.addClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);

core/interfaces/i_focusable_node.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,14 @@ export interface IFocusableNode {
2525
* and a tab index must be present in order for the element to be focusable in
2626
* the DOM).
2727
*
28-
* It's expected the return element will not change for the lifetime of the
29-
* node.
28+
* The returned element must be visible if the node is ever focused via
29+
* FocusManager.focusNode() or FocusManager.focusTree(). It's allowed for an
30+
* element to be hidden until onNodeFocus() is called, or become hidden with a
31+
* call to onNodeBlur().
32+
*
33+
* It's expected the actual returned element will not change for the lifetime
34+
* of the node (that is, its properties can change but a new element should
35+
* never be returned.)
3036
*/
3137
getFocusableElement(): HTMLElement | SVGElement;
3238

@@ -36,4 +42,38 @@ export interface IFocusableNode {
3642
* belongs.
3743
*/
3844
getFocusableTree(): IFocusableTree;
45+
46+
/**
47+
* Called when this node receives active focus.
48+
*
49+
* Note that it's fine for implementations to change visibility modifiers, but
50+
* they should avoid the following:
51+
* - Creating or removing DOM elements (including via the renderer or drawer).
52+
* - Affecting focus via DOM focus() calls or the FocusManager.
53+
*/
54+
onNodeFocus(): void;
55+
56+
/**
57+
* Called when this node loses active focus. It may still have passive focus.
58+
*
59+
* This has the same implementation restrictions as onNodeFocus().
60+
*/
61+
onNodeBlur(): void;
62+
}
63+
64+
/**
65+
* Determines whether the provided object fulfills the contract of
66+
* IFocusableNode.
67+
*
68+
* @param object The object to test.
69+
* @returns Whether the provided object can be used as an IFocusableNode.
70+
*/
71+
export function isFocusableNode(object: any | null): object is IFocusableNode {
72+
return (
73+
object &&
74+
'getFocusableElement' in object &&
75+
'getFocusableTree' in object &&
76+
'onNodeFocus' in object &&
77+
'onNodeBlur' in object
78+
);
3979
}

core/interfaces/i_focusable_tree.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,34 @@ export interface IFocusableTree {
3737
*/
3838
getRootFocusableNode(): IFocusableNode;
3939

40+
/**
41+
* Returns the IFocusableNode of this tree that should receive active focus
42+
* when the tree itself has focused returned to it.
43+
*
44+
* There are some very important notes to consider about a tree's focus
45+
* lifecycle when implementing a version of this method that doesn't return
46+
* null:
47+
* 1. A null previousNode does not guarantee first-time focus state as nodes
48+
* can be deleted.
49+
* 2. This method is only used when the tree itself is focused, either through
50+
* tab navigation or via FocusManager.focusTree(). In many cases, the
51+
* previously focused node will be directly focused instead which will
52+
* bypass this method.
53+
* 3. The default behavior (i.e. returning null here) involves either
54+
* restoring the previous node (previousNode) or focusing the tree's root.
55+
*
56+
* This method is largely intended to provide tree implementations with the
57+
* means of specifying a better default node than their root.
58+
*
59+
* @param previousNode The node that previously held passive focus for this
60+
* tree, or null if the tree hasn't yet been focused.
61+
* @returns The IFocusableNode that should now receive focus, or null if
62+
* default behavior should be used, instead.
63+
*/
64+
getRestoredFocusableNode(
65+
previousNode: IFocusableNode | null,
66+
): IFocusableNode | null;
67+
4068
/**
4169
* Returns all directly nested trees under this tree.
4270
*
@@ -58,4 +86,55 @@ export interface IFocusableTree {
5886
* @param id The ID of the node's focusable HTMLElement or SVGElement.
5987
*/
6088
lookUpFocusableNode(id: string): IFocusableNode | null;
89+
90+
/**
91+
* Called when a node of this tree has received active focus.
92+
*
93+
* Note that a null previousTree does not necessarily indicate that this is
94+
* the first time Blockly is receiving focus. In fact, few assumptions can be
95+
* made about previous focus state as a previous null tree simply indicates
96+
* that Blockly did not hold active focus prior to this tree becoming focused
97+
* (which can happen due to focus exiting the Blockly injection div, or for
98+
* other cases like ephemeral focus).
99+
*
100+
* See IFocusableNode.onNodeFocus() as implementations have the same
101+
* restrictions as with that method.
102+
*
103+
* @param node The node receiving active focus.
104+
* @param previousTree The previous tree that held active focus, or null if
105+
* none.
106+
*/
107+
onTreeFocus(node: IFocusableNode, previousTree: IFocusableTree | null): void;
108+
109+
/**
110+
* Called when the previously actively focused node of this tree is now
111+
* passively focused and there is no other active node of this tree taking its
112+
* place.
113+
*
114+
* This has the same implementation restrictions and considerations as
115+
* onTreeFocus().
116+
*
117+
* @param nextTree The next tree receiving active focus, or null if none (such
118+
* as in the case that Blockly is entirely losing DOM focus).
119+
*/
120+
onTreeBlur(nextTree: IFocusableTree | null): void;
121+
}
122+
123+
/**
124+
* Determines whether the provided object fulfills the contract of
125+
* IFocusableTree.
126+
*
127+
* @param object The object to test.
128+
* @returns Whether the provided object can be used as an IFocusableTree.
129+
*/
130+
export function isFocusableTree(object: any | null): object is IFocusableTree {
131+
return (
132+
object &&
133+
'getRootFocusableNode' in object &&
134+
'getRestoredFocusableNode' in object &&
135+
'getNestedTrees' in object &&
136+
'lookUpFocusableNode' in object &&
137+
'onTreeFocus' in object &&
138+
'onTreeBlur' in object
139+
);
61140
}

0 commit comments

Comments
 (0)