@@ -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 ) ;
0 commit comments