Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions core/focus_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export class FocusManager {
}

/**
* Focuses DOM input on the selected node, and marks it as actively focused.
* Focuses DOM input on the specified node, and marks it as actively focused.
*
* 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).
Expand All @@ -244,17 +244,20 @@ export class FocusManager {
}

// 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).
// isn't actually hooked up to its parent tree correctly. This usually
// happens when calls to focusNode() interleave with asynchronous clean-up
// operations (which can happen due to ephemeral focus and in other cases).
// Fall back to a reasonable default since there's no valid node to focus.
const matchedNode = FocusableTreeTraverser.findFocusableNodeFor(
focusableNode.getFocusableElement(),
nextTree,
);
const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree);
let nodeToFocus = focusableNode;
if (matchedNode !== focusableNode) {
throw Error(
`Attempting to focus node which isn't recognized by its parent tree: ` +
`${focusableNode}.`,
);
const nodeToRestore = nextTree.getRestoredFocusableNode(prevNodeNextTree);
const rootFallback = nextTree.getRootFocusableNode();
nodeToFocus = nodeToRestore ?? prevNodeNextTree ?? rootFallback;
}

const prevNode = this.focusedNode;
Expand All @@ -264,27 +267,26 @@ export class FocusManager {
}

// 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();
if (prevNodeNextTree) {
this.removeHighlight(prevNodeNextTree);
}
// For caution, ensure that the root is always reset since getFocusedNode()
// is expected to return null if the root was highlighted, if the root is
// not the node now being set to active.
if (nextTreeRoot !== focusableNode) {
if (nextTreeRoot !== nodeToFocus) {
this.removeHighlight(nextTreeRoot);
}

if (!this.currentlyHoldsEphemeralFocus) {
// Only change the actively focused node if ephemeral state isn't held.
this.activelyFocusNode(focusableNode, prevTree ?? null);
this.activelyFocusNode(nodeToFocus, prevTree ?? null);
}
this.updateFocusedNode(focusableNode);
this.updateFocusedNode(nodeToFocus);
}

/**
* Ephemerally captures focus for a selected element until the returned lambda
* Ephemerally captures focus for a specific element until the returned lambda
* is called. This is expected to be especially useful for ephemeral UI flows
* like dialogs.
*
Expand Down
4 changes: 4 additions & 0 deletions core/interfaces/i_focusable_tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export interface IFocusableTree {
* 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.
* 4. The provided node may sometimes no longer be valid, such as in the case
* an attempt is made to focus a node that has been recently removed from
* its parent tree. Implementations can check for the validity of the node
* in order to specialize the node to which focus should fall back.
*
* This method is largely intended to provide tree implementations with the
* means of specifying a better default node than their root.
Expand Down
32 changes: 31 additions & 1 deletion tests/mocha/focus_manager_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class FocusableTreeImpl {
this.nestedTrees = nestedTrees;
this.idToNodeMap = {};
this.rootNode = this.addNode(rootElement);
this.fallbackNode = null;
}

addNode(element) {
Expand All @@ -46,12 +47,16 @@ class FocusableTreeImpl {
return node;
}

removeNode(node) {
delete this.idToNodeMap[node.getFocusableElement().id];
}

getRootFocusableNode() {
return this.rootNode;
}

getRestoredFocusableNode() {
return null;
return this.fallbackNode;
}

getNestedTrees() {
Expand Down Expand Up @@ -385,6 +390,31 @@ suite('FocusManager', function () {
// There should be exactly 1 focus event fired from focusNode().
assert.strictEqual(focusCount, 1);
});

test('for orphaned node returns tree root by default', function () {
this.focusManager.registerTree(this.testFocusableTree1);
this.testFocusableTree1.removeNode(this.testFocusableTree1Node1);

this.focusManager.focusNode(this.testFocusableTree1Node1);

// Focusing an invalid node should fall back to the tree root when it has no restoration
// fallback node.
const currentNode = this.focusManager.getFocusedNode();
const treeRoot = this.testFocusableTree1.getRootFocusableNode();
assert.strictEqual(currentNode, treeRoot);
});

test('for orphaned node returns specified fallback node', function () {
this.focusManager.registerTree(this.testFocusableTree1);
this.testFocusableTree1.fallbackNode = this.testFocusableTree1Node2;
this.testFocusableTree1.removeNode(this.testFocusableTree1Node1);

this.focusManager.focusNode(this.testFocusableTree1Node1);

// Focusing an invalid node should fall back to the restored fallback.
const currentNode = this.focusManager.getFocusedNode();
assert.strictEqual(currentNode, this.testFocusableTree1Node2);
});
});

suite('getFocusManager()', function () {
Expand Down