diff --git a/packages/blockly/core/common.ts b/packages/blockly/core/common.ts index a87e1aa129a..87fb19f71fe 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().setPopoverFocusRoot( + (workspace as WorkspaceSvg).getInjectionDiv(), + ); + } } /** diff --git a/packages/blockly/core/dropdowndiv.ts b/packages/blockly/core/dropdowndiv.ts index 75abc720452..ceacf9faf50 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 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( manageEphemeralFocus: boolean, opt_onHide?: () => void, ): boolean { + getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss); + const parentDiv = common.getParentContainer(); parentDiv?.appendChild(div); @@ -669,6 +684,7 @@ export function hideIfOwner( /** 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)'; diff --git a/packages/blockly/core/focus_manager.ts b/packages/blockly/core/focus_manager.ts index 47e4324540d..052006abf2f 100644 --- a/packages/blockly/core/focus_manager.ts +++ b/packages/blockly/core/focus_manager.ts @@ -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. */ 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..d9d49a29dfc 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 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 = '';