-
Notifications
You must be signed in to change notification settings - Fork 3.8k
feat!: Introduce new focus tree/node functions. #8909
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
0772a29
404c20e
c91fed3
4e8bb98
7c0c853
e9ea69d
2564239
096e771
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -60,6 +60,7 @@ export class FocusManager { | |
| registeredTrees: Array<IFocusableTree> = []; | ||
|
|
||
| private currentlyHoldsEphemeralFocus: boolean = false; | ||
| private lockFocusStateChanges: boolean = false; | ||
|
|
||
| constructor( | ||
| addGlobalEventListener: (type: string, listener: EventListener) => void, | ||
|
|
@@ -89,7 +90,16 @@ export class FocusManager { | |
| } | ||
|
|
||
| if (newNode) { | ||
| this.focusNode(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(); | ||
| } | ||
|
|
@@ -108,6 +118,7 @@ export class FocusManager { | |
| * 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}.`); | ||
| } | ||
|
|
@@ -133,6 +144,7 @@ export class FocusManager { | |
| * this manager. | ||
| */ | ||
| unregisterTree(tree: IFocusableTree): void { | ||
| this.ensureManagerIsUnlocked(); | ||
| if (!this.isRegistered(tree)) { | ||
| throw Error(`Attempted to unregister not registered tree: ${tree}.`); | ||
| } | ||
|
|
@@ -192,11 +204,14 @@ export class FocusManager { | |
| * focus. | ||
| */ | ||
| focusTree(focusableTree: IFocusableTree): void { | ||
| this.ensureManagerIsUnlocked(); | ||
| if (!this.isRegistered(focusableTree)) { | ||
| throw Error(`Attempted to focus unregistered tree: ${focusableTree}.`); | ||
| } | ||
| const currNode = FocusableTreeTraverser.findFocusedNode(focusableTree); | ||
| this.focusNode(currNode ?? focusableTree.getRootFocusableNode()); | ||
| const nodeToRestore = focusableTree.getRestoredFocusableNode(currNode); | ||
| const rootFallback = focusableTree.getRootFocusableNode(); | ||
| this.focusNode(nodeToRestore ?? currNode ?? rootFallback); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -205,18 +220,37 @@ export class FocusManager { | |
| * 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. | ||
| * @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; | ||
| if (prevNode && prevNode.getFocusableTree() !== nextTree) { | ||
| this.setNodeToPassive(prevNode); | ||
| const prevTree = prevNode?.getFocusableTree(); | ||
| if (prevNode && prevTree !== nextTree) { | ||
| 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(); | ||
|
|
@@ -229,9 +263,10 @@ export class FocusManager { | |
| if (nextTreeRoot !== focusableNode) { | ||
| this.removeHighlight(nextTreeRoot); | ||
| } | ||
|
|
||
| if (!this.currentlyHoldsEphemeralFocus) { | ||
| // Only change the actively focused node if ephemeral state isn't held. | ||
| this.setNodeToActive(focusableNode); | ||
| this.activelyFocusNode(focusableNode, prevTree ?? null); | ||
| } | ||
| this.focusedNode = focusableNode; | ||
| } | ||
|
|
@@ -257,6 +292,7 @@ export class FocusManager { | |
| takeEphemeralFocus( | ||
| focusableElement: HTMLElement | SVGElement, | ||
| ): ReturnEphemeralFocus { | ||
| this.ensureManagerIsUnlocked(); | ||
| if (this.currentlyHoldsEphemeralFocus) { | ||
| throw Error( | ||
| `Attempted to take ephemeral focus when it's already held, ` + | ||
|
|
@@ -266,7 +302,7 @@ export class FocusManager { | |
| this.currentlyHoldsEphemeralFocus = true; | ||
|
|
||
| if (this.focusedNode) { | ||
| this.setNodeToPassive(this.focusedNode); | ||
| this.passivelyFocusNode(this.focusedNode, null); | ||
| } | ||
| focusableElement.focus(); | ||
|
|
||
|
|
@@ -282,29 +318,66 @@ export class FocusManager { | |
| this.currentlyHoldsEphemeralFocus = false; | ||
|
|
||
| if (this.focusedNode) { | ||
| this.setNodeToActive(this.focusedNode); | ||
| this.activelyFocusNode(this.focusedNode, null); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| private ensureManagerIsUnlocked(): void { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add function comments on private functions (here and below) for future readers.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I usually never add private methods documentation (since usually the code + line comments are sufficient), but happy to add them! |
||
| if (this.lockFocusStateChanges) { | ||
| throw Error( | ||
| 'FocusManager state changes cannot happen in a tree/node focus/blur ' + | ||
| 'callback.', | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| 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.setNodeToPassive(this.focusedNode); | ||
| this.passivelyFocusNode(this.focusedNode, null); | ||
| this.focusedNode = null; | ||
| } | ||
| } | ||
|
|
||
| private setNodeToActive(node: IFocusableNode): void { | ||
| 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; | ||
| node.getFocusableTree().onTreeFocus(node, prevTree); | ||
| node.onNodeFocus(); | ||
| this.lockFocusStateChanges = false; | ||
|
|
||
| this.setNodeToVisualActiveFocus(node); | ||
| node.getFocusableElement().focus(); | ||
| } | ||
|
|
||
| private passivelyFocusNode( | ||
| node: IFocusableNode, | ||
| nextTree: IFocusableTree | null, | ||
| ): void { | ||
| this.lockFocusStateChanges = true; | ||
| node.getFocusableTree().onTreeBlur(nextTree); | ||
| node.onNodeBlur(); | ||
| this.lockFocusStateChanges = false; | ||
|
|
||
| this.setNodeToVisualPassiveFocus(node); | ||
| } | ||
|
|
||
| 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); | ||
| element.focus(); | ||
| } | ||
|
|
||
| private setNodeToPassive(node: IFocusableNode): void { | ||
| 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); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.