fix: Improve focus handling when clicking outside injection div (#9749)

* fix: Improve focus handling when clicking outside injection div

* chore: Use 'popover' in place of 'quasimodal'

* chore: Clarify docs
This commit is contained in:
Aaron Dodson
2026-04-23 11:35:32 -07:00
committed by GitHub
parent 10739f9241
commit 1d159830e4
5 changed files with 117 additions and 6 deletions
+5
View File
@@ -86,6 +86,11 @@ export function getMainWorkspace(): Workspace {
*/
export function setMainWorkspace(workspace: Workspace) {
mainWorkspace = workspace;
if (workspace.rendered) {
getFocusManager().setPopoverFocusRoot(
(workspace as WorkspaceSvg).getInjectionDiv(),
);
}
}
/**
+16
View File
@@ -151,6 +151,19 @@ export function createDom() {
'transform ' + ANIMATION_TIME + 's, ' + 'opacity ' + ANIMATION_TIME + 's';
}
/**
* Deals with the root element that contains this and other popovers losing
* focus by returning ephemeral focus if we hold it and hiding the DropDownDiv.
*/
function handleFocusLoss() {
if (returnEphemeralFocus) {
returnEphemeralFocus(false);
returnEphemeralFocus = null;
}
hide();
}
/**
* Set an element to maintain bounds within. Drop-downs will appear
* within the box of this element if possible.
@@ -370,6 +383,8 @@ export function show<T>(
manageEphemeralFocus: boolean,
opt_onHide?: () => void,
): boolean {
getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss);
const parentDiv = common.getParentContainer();
parentDiv?.appendChild(div);
@@ -669,6 +684,7 @@ export function hideIfOwner<T>(
/** Hide the menu, triggering animation. */
export function hide() {
getFocusManager().unregisterPopoverFocusLossHandler(handleFocusLoss);
// Start the animation by setting the translation and fading out.
// Reset to (initialX, initialY) - i.e., no translation.
div.style.transform = 'translate(0, 0)';
+79 -5
View File
@@ -11,11 +11,15 @@ 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).
* ephemeral UI flow (such as a dialog). Normally, the FocusManager will refocus
* the previously-focused element. If callers do not wish for the FocusManager
* to do so, they may call this method with `restoreFocus` set to false to
* prevent automatic refocusing and leave focus where it is.
*
*
* See FocusManager.takeEphemeralFocus for more details.
*/
export type ReturnEphemeralFocus = () => void;
export type ReturnEphemeralFocus = (restoreFocus?: boolean) => void;
/**
* Represents an IFocusableTree that has been registered for focus management in
@@ -83,6 +87,33 @@ export class FocusManager {
private recentlyLostAllFocus: boolean = false;
private isUpdatingFocusedNode: boolean = false;
/**
* Root element in which popovers (WidgetDiv, DropDownDiv) currently live.
*/
private popoverFocusRoot?: HTMLElement;
/**
* Set of callbacks to invoke if the popover focus root loses focus.
*/
private popoverFocusLossHandlers: Set<() => void> = new Set();
/**
* Handler for focusout in the popover focus root that selectively
* invokes the popover focus loss handlers if focus has truly transitioned
* outside of the focus root, and not e.g. to a different popover.
*/
private popoverFocusOutHandler = (e: FocusEvent) => {
const target = e.relatedTarget;
if (
target === null ||
(target instanceof Node && !this.popoverFocusRoot?.contains(target))
) {
for (const handler of this.popoverFocusLossHandlers) {
handler();
}
}
};
constructor(
addGlobalEventListener: (type: string, listener: EventListener) => void,
) {
@@ -446,7 +477,7 @@ export class FocusManager {
focusableElement.focus({preventScroll: true});
let hasFinishedEphemeralFocus = false;
return () => {
return (restoreFocus = true) => {
if (hasFinishedEphemeralFocus) {
throw Error(
`Attempted to finish ephemeral focus twice for element: ` +
@@ -455,8 +486,7 @@ export class FocusManager {
}
hasFinishedEphemeralFocus = true;
this.currentlyHoldsEphemeralFocus = false;
if (this.focusedNode) {
if (this.focusedNode && restoreFocus) {
this.activelyFocusNode(this.focusedNode, null);
// Even though focus was restored, check if it's lost again. It's
@@ -667,6 +697,50 @@ export class FocusManager {
}
return FocusManager.focusManager;
}
/**
* Sets the current popover focus root. Generally this is active
* workspace's injection div or the explicitly specified parent container for
* the WidgetDiv, DropDownDiv, etc.
*
* @internal
* @param newRoot The new element that contains all popovers.
*/
setPopoverFocusRoot(newRoot: HTMLElement) {
this.popoverFocusRoot?.removeEventListener(
'focusout',
this.popoverFocusOutHandler,
);
this.popoverFocusRoot = newRoot;
this.popoverFocusRoot.addEventListener(
'focusout',
this.popoverFocusOutHandler,
);
}
/**
* Registers a callback to be invoked if the popover focus root loses
* focus. This should only be called by popovers that need to react to
* focus changes by e.g. hiding themselves and resigning ephemeral focus.
*
* @internal
* @param handler A callback function.
*/
registerPopoverFocusLossHandler(handler: () => void) {
this.popoverFocusLossHandlers.add(handler);
}
/**
* Unregisters a previously-registered popover focus loss handler. This
* should only be invoked by popovers when they no longer need to be
* notified of focus loss, typically when they are hidden.
*
* @internal
* @param handler A previously-registered callback function.
*/
unregisterPopoverFocusLossHandler(handler: () => void) {
this.popoverFocusLossHandlers.delete(handler);
}
}
/** Convenience function for FocusManager.getFocusManager. */
+2 -1
View File
@@ -904,7 +904,8 @@ export function registerPerformAction() {
preconditionFn: (workspace) =>
!workspace.isDragging() &&
!dropDownDiv.isVisible() &&
!widgetDiv.isVisible(),
!widgetDiv.isVisible() &&
!getFocusManager().ephemeralFocusTaken(),
callback: (_workspace, e) => {
keyboardNavigationController.setIsActive(true);
const focusedNode = getFocusManager().getFocusedNode();
+15
View File
@@ -61,6 +61,19 @@ export function testOnly_setDiv(newDiv: HTMLDivElement | null) {
}
}
/**
* Deals with the root element that contains this and other popovers losing
* focus by returning ephemeral focus if we hold it and hiding the WidgetDiv.
*/
function handleFocusLoss() {
if (returnEphemeralFocus) {
returnEphemeralFocus(false);
returnEphemeralFocus = null;
}
hide();
}
/**
* Create the widget div and inject it onto the page.
*/
@@ -137,6 +150,7 @@ export function show(
if (manageEphemeralFocus) {
returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div);
}
getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss);
}
/**
@@ -150,6 +164,7 @@ export function hide() {
const div = containerDiv;
if (!div) return;
getFocusManager().unregisterPopoverFocusLossHandler(handleFocusLoss);
div.style.display = 'none';
div.style.left = '';
div.style.top = '';