Skip to content

Commit ac7fea1

Browse files
authored
Merge pull request #8909 from BenHenning/add-focus-manager-callbacks-and-improvements
feat!: Introduce new focus tree/node functions.
2 parents c6e58c4 + 096e771 commit ac7fea1

File tree

5 files changed

+313
-30
lines changed

5 files changed

+313
-30
lines changed

core/focus_manager.ts

Lines changed: 140 additions & 14 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,10 +144,11 @@ 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
}
139-
const treeIndex = this.registeredTrees.findIndex((tree) => tree === tree);
151+
const treeIndex = this.registeredTrees.findIndex((reg) => reg === tree);
140152
this.registeredTrees.splice(treeIndex, 1);
141153

142154
const focusedNode = FocusableTreeTraverser.findFocusedNode(tree);
@@ -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,34 +318,124 @@ 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+
/**
327+
* Ensures that the manager is currently allowing operations that change its
328+
* internal focus state (such as via focusNode()).
329+
*
330+
* If the manager is currently not allowing state changes, an exception is
331+
* thrown.
332+
*/
333+
private ensureManagerIsUnlocked(): void {
334+
if (this.lockFocusStateChanges) {
335+
throw Error(
336+
'FocusManager state changes cannot happen in a tree/node focus/blur ' +
337+
'callback.',
338+
);
339+
}
340+
}
341+
342+
/**
343+
* Defocuses the current actively focused node tracked by the manager, iff
344+
* there's a node being tracked and the manager doesn't have ephemeral focus.
345+
*/
290346
private defocusCurrentFocusedNode(): void {
291347
// The current node will likely be defocused while ephemeral focus is held,
292348
// but internal manager state shouldn't change since the node should be
293349
// restored upon exiting ephemeral focus mode.
294350
if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) {
295-
this.setNodeToPassive(this.focusedNode);
351+
this.passivelyFocusNode(this.focusedNode, null);
296352
this.focusedNode = null;
297353
}
298354
}
299355

300-
private setNodeToActive(node: IFocusableNode): void {
356+
/**
357+
* Marks the specified node as actively focused, also calling related lifecycle
358+
* callback methods for both the node and its parent tree. This ensures that
359+
* the node is properly styled to indicate its active focus.
360+
*
361+
* This does not change the manager's currently tracked node, nor does it
362+
* change any other nodes.
363+
*
364+
* @param node The node to be actively focused.
365+
* @param prevTree The tree of the previously actively focused node, or null
366+
* if there wasn't a previously actively focused node.
367+
*/
368+
private activelyFocusNode(
369+
node: IFocusableNode,
370+
prevTree: IFocusableTree | null,
371+
): void {
372+
// Note that order matters here. Focus callbacks are allowed to change
373+
// element visibility which can influence focusability, including for a
374+
// node's focusable element (which *is* allowed to be invisible until the
375+
// node needs to be focused).
376+
this.lockFocusStateChanges = true;
377+
node.getFocusableTree().onTreeFocus(node, prevTree);
378+
node.onNodeFocus();
379+
this.lockFocusStateChanges = false;
380+
381+
this.setNodeToVisualActiveFocus(node);
382+
node.getFocusableElement().focus();
383+
}
384+
385+
/**
386+
* Marks the specified node as passively focused, also calling related
387+
* lifecycle callback methods for both the node and its parent tree. This
388+
* ensures that the node is properly styled to indicate its passive focus.
389+
*
390+
* This does not change the manager's currently tracked node, nor does it
391+
* change any other nodes.
392+
*
393+
* @param node The node to be passively focused.
394+
* @param nextTree The tree of the node receiving active focus, or null if no
395+
* node will be actively focused.
396+
*/
397+
private passivelyFocusNode(
398+
node: IFocusableNode,
399+
nextTree: IFocusableTree | null,
400+
): void {
401+
this.lockFocusStateChanges = true;
402+
node.getFocusableTree().onTreeBlur(nextTree);
403+
node.onNodeBlur();
404+
this.lockFocusStateChanges = false;
405+
406+
this.setNodeToVisualPassiveFocus(node);
407+
}
408+
409+
/**
410+
* Updates the node's styling to indicate that it should have an active focus
411+
* indicator.
412+
*
413+
* @param node The node to be styled for active focus.
414+
*/
415+
private setNodeToVisualActiveFocus(node: IFocusableNode): void {
301416
const element = node.getFocusableElement();
302417
dom.addClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
303418
dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
304-
element.focus();
305419
}
306420

307-
private setNodeToPassive(node: IFocusableNode): void {
421+
/**
422+
* Updates the node's styling to indicate that it should have a passive focus
423+
* indicator.
424+
*
425+
* @param node The node to be styled for passive focus.
426+
*/
427+
private setNodeToVisualPassiveFocus(node: IFocusableNode): void {
308428
const element = node.getFocusableElement();
309429
dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
310430
dom.addClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
311431
}
312432

433+
/**
434+
* Removes any active/passive indicators for the specified node.
435+
*
436+
* @param node The node which should have neither passive nor active focus
437+
* indication.
438+
*/
313439
private removeHighlight(node: IFocusableNode): void {
314440
const element = node.getFocusableElement();
315441
dom.removeClass(element, FocusManager.ACTIVE_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
}

0 commit comments

Comments
 (0)