From c00ea4ad627c556e407e52e918cce4ccf8684337 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 22 Apr 2026 14:58:20 -0700 Subject: [PATCH 1/3] fix: Improve focus handling when clicking outside injection div --- packages/blockly/core/common.ts | 5 ++ packages/blockly/core/dropdowndiv.ts | 16 +++++ packages/blockly/core/focus_manager.ts | 78 +++++++++++++++++++++++-- packages/blockly/core/shortcut_items.ts | 3 +- packages/blockly/core/widgetdiv.ts | 15 +++++ 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/packages/blockly/core/common.ts b/packages/blockly/core/common.ts index a87e1aa129a..c2169fdfa4c 100644 --- a/packages/blockly/core/common.ts +++ b/packages/blockly/core/common.ts @@ -86,6 +86,11 @@ export function getMainWorkspace(): Workspace { */ export function setMainWorkspace(workspace: Workspace) { mainWorkspace = workspace; + if (workspace.rendered) { + getFocusManager().setQuasiModalFocusRoot( + (workspace as WorkspaceSvg).getInjectionDiv(), + ); + } } /** diff --git a/packages/blockly/core/dropdowndiv.ts b/packages/blockly/core/dropdowndiv.ts index 75abc720452..567822d2696 100644 --- a/packages/blockly/core/dropdowndiv.ts +++ b/packages/blockly/core/dropdowndiv.ts @@ -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 quasi-modals 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( manageEphemeralFocus: boolean, opt_onHide?: () => void, ): boolean { + getFocusManager().registerQuasiModalFocusLossHandler(handleFocusLoss); + const parentDiv = common.getParentContainer(); parentDiv?.appendChild(div); @@ -669,6 +684,7 @@ export function hideIfOwner( /** Hide the menu, triggering animation. */ export function hide() { + getFocusManager().unregisterQuasiModalFocusLossHandler(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)'; diff --git a/packages/blockly/core/focus_manager.ts b/packages/blockly/core/focus_manager.ts index 47e4324540d..07c4b5e6073 100644 --- a/packages/blockly/core/focus_manager.ts +++ b/packages/blockly/core/focus_manager.ts @@ -15,7 +15,7 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; * * 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 +83,33 @@ export class FocusManager { private recentlyLostAllFocus: boolean = false; private isUpdatingFocusedNode: boolean = false; + /** + * Root element in which quasi-modals (WidgetDiv, DropDownDiv) currently live. + */ + private quasiModalFocusRoot?: HTMLElement; + + /** + * Set of callbacks to invoke if the quasi-modal focus root loses focus. + */ + private quasiModalFocusLossHandlers: Set<() => void> = new Set(); + + /** + * Handler for focusout in the quasi-modal focus root that selectively + * invokes the quasi-modal focus loss handlers if focus has truly transitioned + * outside of the focus root, and not e.g. to a different quasi-modal. + */ + private quasiModalFocusOutHandler = (e: FocusEvent) => { + const target = e.relatedTarget; + if ( + target === null || + (target instanceof Node && !this.quasiModalFocusRoot?.contains(target)) + ) { + for (const handler of this.quasiModalFocusLossHandlers) { + handler(); + } + } + }; + constructor( addGlobalEventListener: (type: string, listener: EventListener) => void, ) { @@ -446,7 +473,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 +482,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 +693,50 @@ export class FocusManager { } return FocusManager.focusManager; } + + /** + * Sets the current quasi-modal 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 quasi-modals. + */ + setQuasiModalFocusRoot(newRoot: HTMLElement) { + this.quasiModalFocusRoot?.removeEventListener( + 'focusout', + this.quasiModalFocusOutHandler, + ); + this.quasiModalFocusRoot = newRoot; + this.quasiModalFocusRoot.addEventListener( + 'focusout', + this.quasiModalFocusOutHandler, + ); + } + + /** + * Registers a callback to be invoked if the quasi-modal focus root loses + * focus. This should only be called by quasi-modals that need to react to + * focus changes by e.g. hiding themselves and resigning ephemeral focus. + * + * @internal + * @param handler A callback function. + */ + registerQuasiModalFocusLossHandler(handler: () => void) { + this.quasiModalFocusLossHandlers.add(handler); + } + + /** + * Unregisters a previously-registered quasi-modal focus loss handler. This + * should only be invoked by quasi-modals when they no longer need to be + * notified of focus loss, typically when they are hidden. + * + * @internal + * @param handler A previously-registered callback function. + */ + unregisterQuasiModalFocusLossHandler(handler: () => void) { + this.quasiModalFocusLossHandlers.delete(handler); + } } /** Convenience function for FocusManager.getFocusManager. */ diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 2b8d00d97ea..a4e3f12fe7e 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -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(); diff --git a/packages/blockly/core/widgetdiv.ts b/packages/blockly/core/widgetdiv.ts index b8d04654c0f..0e91c2e88ba 100644 --- a/packages/blockly/core/widgetdiv.ts +++ b/packages/blockly/core/widgetdiv.ts @@ -61,6 +61,19 @@ export function testOnly_setDiv(newDiv: HTMLDivElement | null) { } } +/** + * Deals with the root element that contains this and other quasi-modals 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().registerQuasiModalFocusLossHandler(handleFocusLoss); } /** @@ -150,6 +164,7 @@ export function hide() { const div = containerDiv; if (!div) return; + getFocusManager().unregisterQuasiModalFocusLossHandler(handleFocusLoss); div.style.display = 'none'; div.style.left = ''; div.style.top = ''; From 67952e110585a298d1eb92b996d50b71e7c2d541 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 23 Apr 2026 11:10:42 -0700 Subject: [PATCH 2/3] chore: Use 'popover' in place of 'quasimodal' --- packages/blockly/core/common.ts | 2 +- packages/blockly/core/dropdowndiv.ts | 6 +-- packages/blockly/core/focus_manager.ts | 52 +++++++++++++------------- packages/blockly/core/widgetdiv.ts | 6 +-- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/blockly/core/common.ts b/packages/blockly/core/common.ts index c2169fdfa4c..87fb19f71fe 100644 --- a/packages/blockly/core/common.ts +++ b/packages/blockly/core/common.ts @@ -87,7 +87,7 @@ export function getMainWorkspace(): Workspace { export function setMainWorkspace(workspace: Workspace) { mainWorkspace = workspace; if (workspace.rendered) { - getFocusManager().setQuasiModalFocusRoot( + getFocusManager().setPopoverFocusRoot( (workspace as WorkspaceSvg).getInjectionDiv(), ); } diff --git a/packages/blockly/core/dropdowndiv.ts b/packages/blockly/core/dropdowndiv.ts index 567822d2696..ceacf9faf50 100644 --- a/packages/blockly/core/dropdowndiv.ts +++ b/packages/blockly/core/dropdowndiv.ts @@ -152,7 +152,7 @@ export function createDom() { } /** - * Deals with the root element that contains this and other quasi-modals losing + * 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() { @@ -383,7 +383,7 @@ export function show( manageEphemeralFocus: boolean, opt_onHide?: () => void, ): boolean { - getFocusManager().registerQuasiModalFocusLossHandler(handleFocusLoss); + getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss); const parentDiv = common.getParentContainer(); parentDiv?.appendChild(div); @@ -684,7 +684,7 @@ export function hideIfOwner( /** Hide the menu, triggering animation. */ export function hide() { - getFocusManager().unregisterQuasiModalFocusLossHandler(handleFocusLoss); + 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)'; diff --git a/packages/blockly/core/focus_manager.ts b/packages/blockly/core/focus_manager.ts index 07c4b5e6073..ad3b7939b73 100644 --- a/packages/blockly/core/focus_manager.ts +++ b/packages/blockly/core/focus_manager.ts @@ -84,27 +84,27 @@ export class FocusManager { private isUpdatingFocusedNode: boolean = false; /** - * Root element in which quasi-modals (WidgetDiv, DropDownDiv) currently live. + * Root element in which popovers (WidgetDiv, DropDownDiv) currently live. */ - private quasiModalFocusRoot?: HTMLElement; + private popoverFocusRoot?: HTMLElement; /** - * Set of callbacks to invoke if the quasi-modal focus root loses focus. + * Set of callbacks to invoke if the popover focus root loses focus. */ - private quasiModalFocusLossHandlers: Set<() => void> = new Set(); + private popoverFocusLossHandlers: Set<() => void> = new Set(); /** - * Handler for focusout in the quasi-modal focus root that selectively - * invokes the quasi-modal focus loss handlers if focus has truly transitioned - * outside of the focus root, and not e.g. to a different quasi-modal. + * 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 quasiModalFocusOutHandler = (e: FocusEvent) => { + private popoverFocusOutHandler = (e: FocusEvent) => { const target = e.relatedTarget; if ( target === null || - (target instanceof Node && !this.quasiModalFocusRoot?.contains(target)) + (target instanceof Node && !this.popoverFocusRoot?.contains(target)) ) { - for (const handler of this.quasiModalFocusLossHandlers) { + for (const handler of this.popoverFocusLossHandlers) { handler(); } } @@ -695,47 +695,47 @@ export class FocusManager { } /** - * Sets the current quasi-modal focus root. Generally this is active + * 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 quasi-modals. + * @param newRoot The new element that contains all popovers. */ - setQuasiModalFocusRoot(newRoot: HTMLElement) { - this.quasiModalFocusRoot?.removeEventListener( + setPopoverFocusRoot(newRoot: HTMLElement) { + this.popoverFocusRoot?.removeEventListener( 'focusout', - this.quasiModalFocusOutHandler, + this.popoverFocusOutHandler, ); - this.quasiModalFocusRoot = newRoot; - this.quasiModalFocusRoot.addEventListener( + this.popoverFocusRoot = newRoot; + this.popoverFocusRoot.addEventListener( 'focusout', - this.quasiModalFocusOutHandler, + this.popoverFocusOutHandler, ); } /** - * Registers a callback to be invoked if the quasi-modal focus root loses - * focus. This should only be called by quasi-modals that need to react to + * 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. */ - registerQuasiModalFocusLossHandler(handler: () => void) { - this.quasiModalFocusLossHandlers.add(handler); + registerPopoverFocusLossHandler(handler: () => void) { + this.popoverFocusLossHandlers.add(handler); } /** - * Unregisters a previously-registered quasi-modal focus loss handler. This - * should only be invoked by quasi-modals when they no longer need to be + * 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. */ - unregisterQuasiModalFocusLossHandler(handler: () => void) { - this.quasiModalFocusLossHandlers.delete(handler); + unregisterPopoverFocusLossHandler(handler: () => void) { + this.popoverFocusLossHandlers.delete(handler); } } diff --git a/packages/blockly/core/widgetdiv.ts b/packages/blockly/core/widgetdiv.ts index 0e91c2e88ba..d9d49a29dfc 100644 --- a/packages/blockly/core/widgetdiv.ts +++ b/packages/blockly/core/widgetdiv.ts @@ -62,7 +62,7 @@ export function testOnly_setDiv(newDiv: HTMLDivElement | null) { } /** - * Deals with the root element that contains this and other quasi-modals losing + * 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() { @@ -150,7 +150,7 @@ export function show( if (manageEphemeralFocus) { returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); } - getFocusManager().registerQuasiModalFocusLossHandler(handleFocusLoss); + getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss); } /** @@ -164,7 +164,7 @@ export function hide() { const div = containerDiv; if (!div) return; - getFocusManager().unregisterQuasiModalFocusLossHandler(handleFocusLoss); + getFocusManager().unregisterPopoverFocusLossHandler(handleFocusLoss); div.style.display = 'none'; div.style.left = ''; div.style.top = ''; From ee339fa903e3a1930976e3aafb316b4fae609eec Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 23 Apr 2026 11:21:59 -0700 Subject: [PATCH 3/3] chore: Clarify docs --- packages/blockly/core/focus_manager.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/focus_manager.ts b/packages/blockly/core/focus_manager.ts index ad3b7939b73..052006abf2f 100644 --- a/packages/blockly/core/focus_manager.ts +++ b/packages/blockly/core/focus_manager.ts @@ -11,7 +11,11 @@ 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. */