Files
blockly/core/focus_manager.ts
Ben Henning 0e16b0405a fix: Auto close drop-down divs on lost focus (reapply) (#9213)
## The basics

- [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)

## The details
### Resolves

Fixes https://github.com/google/blockly-keyboard-experimentation/issues/563

### Proposed Changes

This introduces support in `FocusManager` to receive feedback on when an ephemerally focused element entirely loses focus (that is, neither it nor any of its descendants have focus).

This also introduces a behavior change for drop-down divs using the previously mentioned functionality to automatically close themselves when they lose focus for any reason (e.g. clicking outside of the div or tab navigating away from it).

Finally, and **importantly**, this adds a case where ephemeral focus does _not_ automatically return to the previously focused node: when focus is lost to the ephemerally focused element's tree and isn't instead put on another focused node.

### Reason for Changes

Ultimately, focus is probably the best proxy for cases when a drop-down div ought to no longer be open. However, tracking focus only within the scope of the drop-down div utility is rather difficult since a lot of the same problems that `FocusManager` handles also occur here (with regards to both descendants and outside elements receiving focus). It made more sense to expand `FocusManager`'s ephemeral focus support:
- It was easier to implement this `FocusManager` and in a way that's much more robust (since it's leveraging existing event handlers).
- Using `FocusManager` trivialized the solution for drop-down divs.
- There could be other use cases where custom ephemeral focus uses might benefit from knowing when they lose focus.

This new support is enabled by default for all drop-down divs, but can be disabled by callers if they wish to revert to the previous behavior of not auto-closing.

The change for whether to restore ephemeral focus was needed to fix a drawback that arises from the automatic returning of ephemeral focus introduced in this PR: when a user clicks out of an open drop-down menu it will restore focus back to the node that held focus prior to taking ephemeral focus (since it properly hides the drop-down div and restores focus). This creates awkward behavior issues for both mouse and keyboard users:
- For mouse: trying to open a drop-down outside of Blockly will automatically close the drop-down when the Blockly drop-down finishes closing (since focus is stolen back away from the thing the user clicked on).
- For keyboard: tab navigating out of Blockly tries to force focus back to Blockly.

**New in v2 of this PR**: Commit 0363d67c18 is the main one that prevents #9203 from being reintroduced by ensuring that widget div only clears its contents after ephemeral focus has returned. This was missed in the first audit since it wasn't clear that this line, in particular, can cause a div with focus to be removed and thus focus lost: dfd565957b/core/widgetdiv.ts (L156)

### Test Coverage

New tests have been added for both the drop-down div and `FocusManager` components, and have been verified as failing without the new behaviors in place.

There may be other edge cases worth testing for `FocusManager` in particular, but the tests introduced in this PR seem to cover the most important cases.

**New in v2 of this PR**: A test was added to validate that widget div now clears its contents only after ephemeral focus has returned to avoid the desyncing scenario that led to #9203. This test has been verified to fail without the fix. There are also a few new tests being added in the keyboard navigation plugin repository that also validate this behavior at a higher level (see https://github.com/google/blockly-keyboard-experimentation/pull/649).

Demonstration of the new behavior:

[Screen recording 2025-07-01 6.28.37 PM.webm](https://github.com/user-attachments/assets/7af29fed-1ba1-4828-a6cd-65bb94509e72)

### Documentation

No new documentation changes seem needed beyond the code documentation updates.

### Additional Information

It's also possible to change the automatic restoration behavior to be conditional instead of always assuming focus shouldn't be reset if focus leaves the ephemeral element, but that's probably a better change if there's an actual user issue discovered with this approach.

This was originally introduced in #9175 but it was reverted in #9204 due to #9203.
2025-07-07 15:52:38 -07:00

742 lines
29 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import * as dom from './utils/dom.js';
import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
/**
* Type declaration for returning focus to FocusManager upon completing an
* ephemeral UI flow (such as a dialog).
*
* See FocusManager.takeEphemeralFocus for more details.
*/
export type ReturnEphemeralFocus = () => void;
/**
* Type declaration for an optional callback to observe when an element with
* ephemeral focus has its DOM focus changed before ephemeral focus is returned.
*
* See FocusManager.takeEphemeralFocus for more details.
*/
export type EphemeralFocusChangedInDom = (hasDomFocus: boolean) => void;
/**
* Represents an IFocusableTree that has been registered for focus management in
* FocusManager.
*/
class TreeRegistration {
/**
* Constructs a new TreeRegistration.
*
* @param tree The tree being registered.
* @param rootShouldBeAutoTabbable Whether the tree should have automatic
* top-level tab management.
*/
constructor(
readonly tree: IFocusableTree,
readonly rootShouldBeAutoTabbable: boolean,
) {}
}
/**
* A per-page singleton that manages Blockly focus across one or more
* IFocusableTrees, and bidirectionally synchronizes this focus with the DOM.
*
* Callers that wish to explicitly change input focus for select Blockly
* components on the page should use the focus functions in this manager.
*
* The manager is responsible for handling focus events from the DOM (which may
* may arise from users clicking on page elements) and ensuring that
* corresponding IFocusableNodes are clearly marked as actively/passively
* highlighted in the same way that this would be represented with calls to
* focusNode().
*/
export class FocusManager {
/**
* The CSS class assigned to IFocusableNode elements that presently have
* active DOM and Blockly focus.
*
* This should never be used directly. Instead, rely on FocusManager to ensure
* nodes have active focus (either automatically through DOM focus or manually
* through the various focus* methods provided by this class).
*
* It's recommended to not query using this class name, either. Instead, use
* FocusableTreeTraverser or IFocusableTree's methods to find a specific node.
*/
static readonly ACTIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyActiveFocus';
/**
* The CSS class assigned to IFocusableNode elements that presently have
* passive focus (that is, they were the most recent node in their relative
* tree to have active focus--see ACTIVE_FOCUS_NODE_CSS_CLASS_NAME--and will
* receive active focus again if their surrounding tree is requested to become
* focused, i.e. using focusTree below).
*
* See ACTIVE_FOCUS_NODE_CSS_CLASS_NAME for caveats and limitations around
* using this constant directly (generally it never should need to be used).
*/
static readonly PASSIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyPassiveFocus';
private focusedNode: IFocusableNode | null = null;
private previouslyFocusedNode: IFocusableNode | null = null;
private registeredTrees: Array<TreeRegistration> = [];
private ephemerallyFocusedElement: HTMLElement | SVGElement | null = null;
private ephemeralDomFocusChangedCallback: EphemeralFocusChangedInDom | null =
null;
private ephemerallyFocusedElementCurrentlyHasFocus: boolean = false;
private lockFocusStateChanges: boolean = false;
private recentlyLostAllFocus: boolean = false;
private isUpdatingFocusedNode: boolean = false;
constructor(
addGlobalEventListener: (type: string, listener: EventListener) => void,
) {
// Note that 'element' here is the element *gaining* focus.
const maybeFocus = (element: Element | EventTarget | null) => {
// Skip processing the event if the focused node is currently updating.
if (this.isUpdatingFocusedNode) return;
this.recentlyLostAllFocus = !element;
let newNode: IFocusableNode | null | undefined = null;
if (element instanceof HTMLElement || element instanceof SVGElement) {
// If the target losing or gaining focus maps to any tree, then it
// should be updated. Per the contract of findFocusableNodeFor only one
// tree should claim the element, so the search can be exited early.
for (const reg of this.registeredTrees) {
const tree = reg.tree;
newNode = FocusableTreeTraverser.findFocusableNodeFor(element, tree);
if (newNode) break;
}
}
if (newNode && newNode.canBeFocused()) {
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();
}
const ephemeralFocusElem = this.ephemerallyFocusedElement;
if (ephemeralFocusElem) {
const hadFocus = this.ephemerallyFocusedElementCurrentlyHasFocus;
const hasFocus =
!!element &&
element instanceof Node &&
ephemeralFocusElem.contains(element);
if (hadFocus !== hasFocus) {
if (this.ephemeralDomFocusChangedCallback) {
this.ephemeralDomFocusChangedCallback(hasFocus);
}
this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus;
}
}
};
// Register root document focus listeners for tracking when focus leaves all
// tracked focusable trees. Note that focusin and focusout can be somewhat
// overlapping in the information that they provide. This is fine because
// they both aim to check for focus changes on the element gaining or having
// received focus, and maybeFocus should behave relatively deterministic.
addGlobalEventListener('focusin', (event) => {
if (!(event instanceof FocusEvent)) return;
// When something receives focus, always use the current active element as
// the source of truth.
maybeFocus(document.activeElement);
});
addGlobalEventListener('focusout', (event) => {
if (!(event instanceof FocusEvent)) return;
// When something loses focus, it seems that document.activeElement may
// not necessarily be correct. Instead, use relatedTarget.
maybeFocus(event.relatedTarget);
});
}
/**
* Registers a new IFocusableTree for automatic focus management.
*
* If the tree currently has an element with DOM focus, it will not affect the
* internal state in this manager until the focus changes to a new,
* now-monitored element/node.
*
* This function throws if the provided tree is already currently registered
* in this manager. Use isRegistered to check in cases when it can't be
* certain whether the tree has been registered.
*
* The tree's registration can be customized to configure automatic tab stops.
* This specifically provides capability for the user to be able to tab
* navigate to the root of the tree but only when the tree doesn't hold active
* focus. If this functionality is disabled then the tree's root will
* automatically be made focusable (but not tabbable) when it is first focused
* in the same way as any other focusable node.
*
* @param tree The IFocusableTree to register.
* @param rootShouldBeAutoTabbable Whether the root of this tree should be
* added as a top-level page tab stop when it doesn't hold active focus.
*/
registerTree(
tree: IFocusableTree,
rootShouldBeAutoTabbable: boolean = false,
): void {
this.ensureManagerIsUnlocked();
if (this.isRegistered(tree)) {
throw Error(`Attempted to re-register already registered tree: ${tree}.`);
}
this.registeredTrees.push(
new TreeRegistration(tree, rootShouldBeAutoTabbable),
);
const rootElement = tree.getRootFocusableNode().getFocusableElement();
if (!rootElement.id || rootElement.id === 'null') {
throw Error(
`Attempting to register a tree with a root element that has an ` +
`invalid ID: ${tree}.`,
);
}
if (rootShouldBeAutoTabbable) {
rootElement.tabIndex = 0;
}
}
/**
* Returns whether the specified tree has already been registered in this
* manager using registerTree and hasn't yet been unregistered using
* unregisterTree.
*/
isRegistered(tree: IFocusableTree): boolean {
return !!this.lookUpRegistration(tree);
}
/**
* Returns the TreeRegistration for the specified tree, or null if the tree is
* not currently registered.
*/
private lookUpRegistration(tree: IFocusableTree): TreeRegistration | null {
return this.registeredTrees.find((reg) => reg.tree === tree) ?? null;
}
/**
* Unregisters a IFocusableTree from automatic focus management.
*
* If the tree had a previous focused node, it will have its highlight
* removed. This function does NOT change DOM focus.
*
* This function throws if the provided tree is not currently registered in
* this manager.
*
* This function will reset the tree's root element tabindex if the tree was
* registered with automatic tab management.
*/
unregisterTree(tree: IFocusableTree): void {
this.ensureManagerIsUnlocked();
if (!this.isRegistered(tree)) {
throw Error(`Attempted to unregister not registered tree: ${tree}.`);
}
const treeIndex = this.registeredTrees.findIndex(
(reg) => reg.tree === tree,
);
const registration = this.registeredTrees[treeIndex];
this.registeredTrees.splice(treeIndex, 1);
const focusedNode = FocusableTreeTraverser.findFocusedNode(tree);
const root = tree.getRootFocusableNode();
if (focusedNode) this.removeHighlight(focusedNode);
if (this.focusedNode === focusedNode || this.focusedNode === root) {
this.updateFocusedNode(null);
}
this.removeHighlight(root);
if (registration.rootShouldBeAutoTabbable) {
tree
.getRootFocusableNode()
.getFocusableElement()
.removeAttribute('tabindex');
}
}
/**
* Returns the current IFocusableTree that has focus, or null if none
* currently do.
*
* Note also that if ephemeral focus is currently captured (e.g. using
* takeEphemeralFocus) then the returned tree here may not currently have DOM
* focus.
*/
getFocusedTree(): IFocusableTree | null {
return this.focusedNode?.getFocusableTree() ?? null;
}
/**
* Returns the current IFocusableNode with focus (which is always tied to a
* focused IFocusableTree), or null if there isn't one.
*
* Note that this function will maintain parity with
* IFocusableTree.getFocusedNode(). That is, if a tree itself has focus but
* none of its non-root children do, this will return null but
* getFocusedTree() will not.
*
* Note also that if ephemeral focus is currently captured (e.g. using
* takeEphemeralFocus) then the returned node here may not currently have DOM
* focus.
*/
getFocusedNode(): IFocusableNode | null {
return this.focusedNode;
}
/**
* Focuses the specific IFocusableTree. This either means restoring active
* focus to the tree's passively focused node, or focusing the tree's root
* node.
*
* Note that if the specified tree already has a focused node then this will
* not change any existing focus (unless that node has passive focus, then it
* will be restored to active focus).
*
* See getFocusedNode for details on how other nodes are affected.
*
* @param focusableTree The tree that should receive active
* focus.
*/
focusTree(focusableTree: IFocusableTree): void {
this.ensureManagerIsUnlocked();
if (!this.isRegistered(focusableTree)) {
throw Error(`Attempted to focus unregistered tree: ${focusableTree}.`);
}
const currNode = FocusableTreeTraverser.findFocusedNode(focusableTree);
const nodeToRestore = focusableTree.getRestoredFocusableNode(currNode);
const rootFallback = focusableTree.getRootFocusableNode();
this.focusNode(nodeToRestore ?? currNode ?? rootFallback);
}
/**
* 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).
*
* **Important**: If the provided node is not able to be focused (e.g. its
* canBeFocused() method returns false), it will be ignored and any existing
* focus state will remain unchanged.
*
* Note that this may update the specified node's element's tabindex to ensure
* that it can be properly read out by screenreaders while focused.
*
* @param focusableNode The node that should receive active focus.
*/
focusNode(focusableNode: IFocusableNode): void {
this.ensureManagerIsUnlocked();
const mustRestoreUpdatingNode = !this.ephemerallyFocusedElement;
if (mustRestoreUpdatingNode) {
// Disable state syncing from DOM events since possible calls to focus()
// below will loop a call back to focusNode().
this.isUpdatingFocusedNode = true;
}
// Double check that state wasn't desynchronized in the background. See:
// https://github.com/google/blockly-keyboard-experimentation/issues/87.
// This is only done for the case where the same node is being focused twice
// since other cases should automatically correct (due to the rest of the
// routine running as normal).
const prevFocusedElement = this.focusedNode?.getFocusableElement();
const hasDesyncedState = prevFocusedElement !== document.activeElement;
if (this.focusedNode === focusableNode && !hasDesyncedState) {
if (mustRestoreUpdatingNode) {
// Reenable state syncing from DOM events.
this.isUpdatingFocusedNode = false;
}
return; // State is unchanged.
}
if (!focusableNode.canBeFocused()) {
// This node can't be focused.
console.warn("Trying to focus a node that can't be focused.");
if (mustRestoreUpdatingNode) {
// Reenable state syncing from DOM events.
this.isUpdatingFocusedNode = false;
}
return;
}
const nextTree = focusableNode.getFocusableTree();
if (!this.isRegistered(nextTree)) {
throw Error(`Attempted to focus unregistered node: ${focusableNode}.`);
}
const focusableNodeElement = focusableNode.getFocusableElement();
if (!focusableNodeElement.id || focusableNodeElement.id === 'null') {
// Warn that the ID is invalid, but continue execution since an invalid ID
// will result in an unmatched (null) node. Since a request to focus
// something was initiated, the code below will attempt to find the next
// best thing to focus, instead.
console.warn('Trying to focus a node that has an invalid ID.');
}
// Safety check for ensuring focusNode() doesn't get called for a node that
// 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(
focusableNodeElement,
nextTree,
);
const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree);
let nodeToFocus = focusableNode;
if (matchedNode !== focusableNode) {
const nodeToRestore = nextTree.getRestoredFocusableNode(prevNodeNextTree);
const rootFallback = nextTree.getRootFocusableNode();
nodeToFocus = nodeToRestore ?? prevNodeNextTree ?? rootFallback;
}
const prevNode = this.focusedNode;
const prevTree = prevNode?.getFocusableTree();
if (prevNode) {
this.passivelyFocusNode(prevNode, nextTree);
}
// If there's a focused node in the new node's tree, ensure it's reset.
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 !== nodeToFocus) {
this.removeHighlight(nextTreeRoot);
}
if (!this.ephemerallyFocusedElement) {
// Only change the actively focused node if ephemeral state isn't held.
this.activelyFocusNode(nodeToFocus, prevTree ?? null);
}
this.updateFocusedNode(nodeToFocus);
if (mustRestoreUpdatingNode) {
// Reenable state syncing from DOM events.
this.isUpdatingFocusedNode = false;
}
}
/**
* 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.
*
* IMPORTANT: the returned lambda *must* be called, otherwise automatic focus
* will no longer work anywhere on the page. It is highly recommended to tie
* the lambda call to the closure of the corresponding UI so that if input is
* manually changed to an element outside of the ephemeral UI, the UI should
* close and automatic input restored. Note that this lambda must be called
* exactly once and that subsequent calls will throw an error.
*
* Note that the manager will continue to track DOM input signals even when
* ephemeral focus is active, but it won't actually change node state until
* the returned lambda is called. Additionally, only 1 ephemeral focus context
* can be active at any given time (attempting to activate more than one
* simultaneously will result in an error being thrown).
*
* Important details regarding the onFocusChangedInDom callback:
* - This method will be called initially with a value of 'true' indicating
* that the ephemeral element has been focused, so callers can rely on that,
* if needed, for initialization logic.
* - It's safe to end ephemeral focus in this callback (and is encouraged for
* callers that wish to automatically end ephemeral focus when the user
* directs focus outside of the element).
* - The element AND all of its descendants are tracked for focus. That means
* the callback will ONLY be called with a value of 'false' if focus
* completely leaves the DOM tree for the provided focusable element.
* - It's invalid to return focus on the very first call to the callback,
* however this is expected to be impossible, anyway, since this method
* won't return until after the first call to the callback (thus there will
* be no means to return ephemeral focus).
*
* @param focusableElement The element that should be focused until returned.
* @param onFocusChangedInDom An optional callback which will be notified
* whenever the provided element's focus changes before ephemeral focus is
* returned. See the details above for specifics.
* @returns A ReturnEphemeralFocus that must be called when ephemeral focus
* should end.
*/
takeEphemeralFocus(
focusableElement: HTMLElement | SVGElement,
onFocusChangedInDom: EphemeralFocusChangedInDom | null = null,
): ReturnEphemeralFocus {
this.ensureManagerIsUnlocked();
if (this.ephemerallyFocusedElement) {
throw Error(
`Attempted to take ephemeral focus when it's already held, ` +
`with new element: ${focusableElement}.`,
);
}
this.ephemerallyFocusedElement = focusableElement;
this.ephemeralDomFocusChangedCallback = onFocusChangedInDom;
if (this.focusedNode) {
this.passivelyFocusNode(this.focusedNode, null);
}
focusableElement.focus();
this.ephemerallyFocusedElementCurrentlyHasFocus = true;
const focusedNodeAtStart = this.focusedNode;
let hasFinishedEphemeralFocus = false;
return () => {
if (hasFinishedEphemeralFocus) {
throw Error(
`Attempted to finish ephemeral focus twice for element: ` +
`${focusableElement}.`,
);
}
hasFinishedEphemeralFocus = true;
this.ephemerallyFocusedElement = null;
this.ephemeralDomFocusChangedCallback = null;
const hadEphemeralFocusAtEnd =
this.ephemerallyFocusedElementCurrentlyHasFocus;
this.ephemerallyFocusedElementCurrentlyHasFocus = false;
// If the user forced away DOM focus during ephemeral focus, then
// determine whether focus should be restored back to a focusable node
// after ephemeral focus ends. Generally it shouldn't be, but in some
// cases (such as the user focusing an actual focusable node) it then
// should be.
const hasNewFocusedNode = focusedNodeAtStart !== this.focusedNode;
const shouldRestoreToNode = hasNewFocusedNode || hadEphemeralFocusAtEnd;
if (this.focusedNode && shouldRestoreToNode) {
this.activelyFocusNode(this.focusedNode, null);
// Even though focus was restored, check if it's lost again. It's
// possible for the browser to force focus away from all elements once
// the ephemeral element disappears. This ensures focus is restored.
const capturedNode = this.focusedNode;
setTimeout(() => {
// These checks are set up to minimize the risk that a legitimate
// focus change occurred within the delay that this would override.
if (
!this.focusedNode &&
this.previouslyFocusedNode === capturedNode &&
this.recentlyLostAllFocus
) {
this.focusNode(capturedNode);
}
}, 0);
} else {
// If the ephemeral element lost focus then do not force it back since
// that likely will override the user's own attempt to move focus away
// from the ephemeral experience.
this.defocusCurrentFocusedNode();
}
};
}
/**
* @returns whether something is currently holding ephemeral focus
*/
ephemeralFocusTaken(): boolean {
return !!this.ephemerallyFocusedElement;
}
/**
* Ensures that the manager is currently allowing operations that change its
* internal focus state (such as via focusNode()).
*
* If the manager is currently not allowing state changes, an exception is
* thrown.
*/
private ensureManagerIsUnlocked(): void {
if (this.lockFocusStateChanges) {
throw Error(
'FocusManager state changes cannot happen in a tree/node focus/blur ' +
'callback.',
);
}
}
/**
* Updates the internally tracked focused node to the specified node, or null
* if focus is being lost. This also updates previous focus tracking.
*
* @param newFocusedNode The new node to set as focused.
*/
private updateFocusedNode(newFocusedNode: IFocusableNode | null) {
this.previouslyFocusedNode = this.focusedNode;
this.focusedNode = newFocusedNode;
}
/**
* Defocuses the current actively focused node tracked by the manager, iff
* there's a node being tracked and the manager doesn't have ephemeral focus.
*/
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.ephemerallyFocusedElement) {
this.passivelyFocusNode(this.focusedNode, null);
this.updateFocusedNode(null);
}
}
/**
* Marks the specified node as actively focused, also calling related
* lifecycle callback methods for both the node and its parent tree. This
* ensures that the node is properly styled to indicate its active focus.
*
* This does not change the manager's currently tracked node, nor does it
* change any other nodes.
*
* @param node The node to be actively focused.
* @param prevTree The tree of the previously actively focused node, or null
* if there wasn't a previously actively focused node.
*/
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;
const tree = node.getFocusableTree();
const elem = node.getFocusableElement();
const nextTreeReg = this.lookUpRegistration(tree);
const treeIsTabManaged = nextTreeReg?.rootShouldBeAutoTabbable;
if (tree !== prevTree) {
tree.onTreeFocus(node, prevTree);
if (treeIsTabManaged) {
// If this node's tree has its tab auto-managed, ensure that it's no
// longer tabbable now that it holds active focus.
tree.getRootFocusableNode().getFocusableElement().tabIndex = -1;
}
}
node.onNodeFocus();
this.lockFocusStateChanges = false;
// The tab index should be set in all cases where:
// - It doesn't overwrite an pre-set tab index for the node.
// - The node is part of a tree whose tab index is unmanaged.
// OR
// - The node is part of a managed tree but this isn't the root. Managed
// roots are ignored since they are always overwritten to have a tab index
// of -1 with active focus so that they cannot be tab navigated.
//
// Setting the tab index ensures that the node's focusable element can
// actually receive DOM focus.
if (!treeIsTabManaged || node !== tree.getRootFocusableNode()) {
if (!elem.hasAttribute('tabindex')) elem.tabIndex = -1;
}
this.setNodeToVisualActiveFocus(node);
elem.focus();
}
/**
* Marks the specified node as passively focused, also calling related
* lifecycle callback methods for both the node and its parent tree. This
* ensures that the node is properly styled to indicate its passive focus.
*
* This does not change the manager's currently tracked node, nor does it
* change any other nodes.
*
* @param node The node to be passively focused.
* @param nextTree The tree of the node receiving active focus, or null if no
* node will be actively focused.
*/
private passivelyFocusNode(
node: IFocusableNode,
nextTree: IFocusableTree | null,
): void {
this.lockFocusStateChanges = true;
const tree = node.getFocusableTree();
if (tree !== nextTree) {
tree.onTreeBlur(nextTree);
const reg = this.lookUpRegistration(tree);
if (reg?.rootShouldBeAutoTabbable) {
// If this node's tree has its tab auto-managed, ensure that it's now
// tabbable since it no longer holds active focus.
tree.getRootFocusableNode().getFocusableElement().tabIndex = 0;
}
}
node.onNodeBlur();
this.lockFocusStateChanges = false;
if (tree !== nextTree) {
this.setNodeToVisualPassiveFocus(node);
}
}
/**
* Updates the node's styling to indicate that it should have an active focus
* indicator.
*
* @param node The node to be styled for active focus.
*/
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);
}
/**
* Updates the node's styling to indicate that it should have a passive focus
* indicator.
*
* @param node The node to be styled for passive focus.
*/
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);
}
/**
* Removes any active/passive indicators for the specified node.
*
* @param node The node which should have neither passive nor active focus
* indication.
*/
private removeHighlight(node: IFocusableNode): void {
const element = node.getFocusableElement();
dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
}
private static focusManager: FocusManager | null = null;
/**
* Returns the page-global FocusManager.
*
* The returned instance is guaranteed to not change across function calls,
* but may change across page loads.
*/
static getFocusManager(): FocusManager {
if (!FocusManager.focusManager) {
FocusManager.focusManager = new FocusManager(document.addEventListener);
}
return FocusManager.focusManager;
}
}
/** Convenience function for FocusManager.getFocusManager. */
export function getFocusManager(): FocusManager {
return FocusManager.getFocusManager();
}