mirror of
https://github.com/google/blockly.git
synced 2025-12-16 06:10:12 +01:00
* Revert "fix: Auto-close widget divs on lost focus (#9216)" This reverts commitbea183d85d. * Revert "fix: Auto close drop-down divs on lost focus (reapply) (#9213)" This reverts commit0e16b0405a.
672 lines
26 KiB
TypeScript
672 lines
26 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;
|
|
|
|
/**
|
|
* 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 currentlyHoldsEphemeralFocus: 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();
|
|
}
|
|
};
|
|
|
|
// 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.currentlyHoldsEphemeralFocus;
|
|
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.currentlyHoldsEphemeralFocus) {
|
|
// 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).
|
|
*/
|
|
takeEphemeralFocus(
|
|
focusableElement: HTMLElement | SVGElement,
|
|
): ReturnEphemeralFocus {
|
|
this.ensureManagerIsUnlocked();
|
|
if (this.currentlyHoldsEphemeralFocus) {
|
|
throw Error(
|
|
`Attempted to take ephemeral focus when it's already held, ` +
|
|
`with new element: ${focusableElement}.`,
|
|
);
|
|
}
|
|
this.currentlyHoldsEphemeralFocus = true;
|
|
|
|
if (this.focusedNode) {
|
|
this.passivelyFocusNode(this.focusedNode, null);
|
|
}
|
|
focusableElement.focus();
|
|
|
|
let hasFinishedEphemeralFocus = false;
|
|
return () => {
|
|
if (hasFinishedEphemeralFocus) {
|
|
throw Error(
|
|
`Attempted to finish ephemeral focus twice for element: ` +
|
|
`${focusableElement}.`,
|
|
);
|
|
}
|
|
hasFinishedEphemeralFocus = true;
|
|
this.currentlyHoldsEphemeralFocus = false;
|
|
|
|
if (this.focusedNode) {
|
|
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);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @returns whether something is currently holding ephemeral focus
|
|
*/
|
|
ephemeralFocusTaken(): boolean {
|
|
return this.currentlyHoldsEphemeralFocus;
|
|
}
|
|
|
|
/**
|
|
* 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.currentlyHoldsEphemeralFocus) {
|
|
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();
|
|
}
|