Skip to content

Commit 1d15983

Browse files
authored
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
1 parent 10739f9 commit 1d15983

5 files changed

Lines changed: 117 additions & 6 deletions

File tree

packages/blockly/core/common.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ export function getMainWorkspace(): Workspace {
8686
*/
8787
export function setMainWorkspace(workspace: Workspace) {
8888
mainWorkspace = workspace;
89+
if (workspace.rendered) {
90+
getFocusManager().setPopoverFocusRoot(
91+
(workspace as WorkspaceSvg).getInjectionDiv(),
92+
);
93+
}
8994
}
9095

9196
/**

packages/blockly/core/dropdowndiv.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,19 @@ export function createDom() {
151151
'transform ' + ANIMATION_TIME + 's, ' + 'opacity ' + ANIMATION_TIME + 's';
152152
}
153153

154+
/**
155+
* Deals with the root element that contains this and other popovers losing
156+
* focus by returning ephemeral focus if we hold it and hiding the DropDownDiv.
157+
*/
158+
function handleFocusLoss() {
159+
if (returnEphemeralFocus) {
160+
returnEphemeralFocus(false);
161+
returnEphemeralFocus = null;
162+
}
163+
164+
hide();
165+
}
166+
154167
/**
155168
* Set an element to maintain bounds within. Drop-downs will appear
156169
* within the box of this element if possible.
@@ -370,6 +383,8 @@ export function show<T>(
370383
manageEphemeralFocus: boolean,
371384
opt_onHide?: () => void,
372385
): boolean {
386+
getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss);
387+
373388
const parentDiv = common.getParentContainer();
374389
parentDiv?.appendChild(div);
375390

@@ -669,6 +684,7 @@ export function hideIfOwner<T>(
669684

670685
/** Hide the menu, triggering animation. */
671686
export function hide() {
687+
getFocusManager().unregisterPopoverFocusLossHandler(handleFocusLoss);
672688
// Start the animation by setting the translation and fading out.
673689
// Reset to (initialX, initialY) - i.e., no translation.
674690
div.style.transform = 'translate(0, 0)';

packages/blockly/core/focus_manager.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
1111

1212
/**
1313
* Type declaration for returning focus to FocusManager upon completing an
14-
* ephemeral UI flow (such as a dialog).
14+
* ephemeral UI flow (such as a dialog). Normally, the FocusManager will refocus
15+
* the previously-focused element. If callers do not wish for the FocusManager
16+
* to do so, they may call this method with `restoreFocus` set to false to
17+
* prevent automatic refocusing and leave focus where it is.
18+
*
1519
*
1620
* See FocusManager.takeEphemeralFocus for more details.
1721
*/
18-
export type ReturnEphemeralFocus = () => void;
22+
export type ReturnEphemeralFocus = (restoreFocus?: boolean) => void;
1923

2024
/**
2125
* Represents an IFocusableTree that has been registered for focus management in
@@ -83,6 +87,33 @@ export class FocusManager {
8387
private recentlyLostAllFocus: boolean = false;
8488
private isUpdatingFocusedNode: boolean = false;
8589

90+
/**
91+
* Root element in which popovers (WidgetDiv, DropDownDiv) currently live.
92+
*/
93+
private popoverFocusRoot?: HTMLElement;
94+
95+
/**
96+
* Set of callbacks to invoke if the popover focus root loses focus.
97+
*/
98+
private popoverFocusLossHandlers: Set<() => void> = new Set();
99+
100+
/**
101+
* Handler for focusout in the popover focus root that selectively
102+
* invokes the popover focus loss handlers if focus has truly transitioned
103+
* outside of the focus root, and not e.g. to a different popover.
104+
*/
105+
private popoverFocusOutHandler = (e: FocusEvent) => {
106+
const target = e.relatedTarget;
107+
if (
108+
target === null ||
109+
(target instanceof Node && !this.popoverFocusRoot?.contains(target))
110+
) {
111+
for (const handler of this.popoverFocusLossHandlers) {
112+
handler();
113+
}
114+
}
115+
};
116+
86117
constructor(
87118
addGlobalEventListener: (type: string, listener: EventListener) => void,
88119
) {
@@ -446,7 +477,7 @@ export class FocusManager {
446477
focusableElement.focus({preventScroll: true});
447478

448479
let hasFinishedEphemeralFocus = false;
449-
return () => {
480+
return (restoreFocus = true) => {
450481
if (hasFinishedEphemeralFocus) {
451482
throw Error(
452483
`Attempted to finish ephemeral focus twice for element: ` +
@@ -455,8 +486,7 @@ export class FocusManager {
455486
}
456487
hasFinishedEphemeralFocus = true;
457488
this.currentlyHoldsEphemeralFocus = false;
458-
459-
if (this.focusedNode) {
489+
if (this.focusedNode && restoreFocus) {
460490
this.activelyFocusNode(this.focusedNode, null);
461491

462492
// Even though focus was restored, check if it's lost again. It's
@@ -667,6 +697,50 @@ export class FocusManager {
667697
}
668698
return FocusManager.focusManager;
669699
}
700+
701+
/**
702+
* Sets the current popover focus root. Generally this is active
703+
* workspace's injection div or the explicitly specified parent container for
704+
* the WidgetDiv, DropDownDiv, etc.
705+
*
706+
* @internal
707+
* @param newRoot The new element that contains all popovers.
708+
*/
709+
setPopoverFocusRoot(newRoot: HTMLElement) {
710+
this.popoverFocusRoot?.removeEventListener(
711+
'focusout',
712+
this.popoverFocusOutHandler,
713+
);
714+
this.popoverFocusRoot = newRoot;
715+
this.popoverFocusRoot.addEventListener(
716+
'focusout',
717+
this.popoverFocusOutHandler,
718+
);
719+
}
720+
721+
/**
722+
* Registers a callback to be invoked if the popover focus root loses
723+
* focus. This should only be called by popovers that need to react to
724+
* focus changes by e.g. hiding themselves and resigning ephemeral focus.
725+
*
726+
* @internal
727+
* @param handler A callback function.
728+
*/
729+
registerPopoverFocusLossHandler(handler: () => void) {
730+
this.popoverFocusLossHandlers.add(handler);
731+
}
732+
733+
/**
734+
* Unregisters a previously-registered popover focus loss handler. This
735+
* should only be invoked by popovers when they no longer need to be
736+
* notified of focus loss, typically when they are hidden.
737+
*
738+
* @internal
739+
* @param handler A previously-registered callback function.
740+
*/
741+
unregisterPopoverFocusLossHandler(handler: () => void) {
742+
this.popoverFocusLossHandlers.delete(handler);
743+
}
670744
}
671745

672746
/** Convenience function for FocusManager.getFocusManager. */

packages/blockly/core/shortcut_items.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -904,7 +904,8 @@ export function registerPerformAction() {
904904
preconditionFn: (workspace) =>
905905
!workspace.isDragging() &&
906906
!dropDownDiv.isVisible() &&
907-
!widgetDiv.isVisible(),
907+
!widgetDiv.isVisible() &&
908+
!getFocusManager().ephemeralFocusTaken(),
908909
callback: (_workspace, e) => {
909910
keyboardNavigationController.setIsActive(true);
910911
const focusedNode = getFocusManager().getFocusedNode();

packages/blockly/core/widgetdiv.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ export function testOnly_setDiv(newDiv: HTMLDivElement | null) {
6161
}
6262
}
6363

64+
/**
65+
* Deals with the root element that contains this and other popovers losing
66+
* focus by returning ephemeral focus if we hold it and hiding the WidgetDiv.
67+
*/
68+
function handleFocusLoss() {
69+
if (returnEphemeralFocus) {
70+
returnEphemeralFocus(false);
71+
returnEphemeralFocus = null;
72+
}
73+
74+
hide();
75+
}
76+
6477
/**
6578
* Create the widget div and inject it onto the page.
6679
*/
@@ -137,6 +150,7 @@ export function show(
137150
if (manageEphemeralFocus) {
138151
returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div);
139152
}
153+
getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss);
140154
}
141155

142156
/**
@@ -150,6 +164,7 @@ export function hide() {
150164

151165
const div = containerDiv;
152166
if (!div) return;
167+
getFocusManager().unregisterPopoverFocusLossHandler(handleFocusLoss);
153168
div.style.display = 'none';
154169
div.style.left = '';
155170
div.style.top = '';

0 commit comments

Comments
 (0)