Files
blockly/core/widgetdiv.ts
Ben Henning bea183d85d fix: Auto-close widget divs on lost focus (#9216)
## The basics

- [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)

## The details
### Resolves

Fixes https://github.com/google/blockly-keyboard-experimentation/issues/563

### Proposed Changes

This expands the functionality introduced in #9213 to also include widget divs.

### Reason for Changes

MakeCode makes use of widget div in several field editors, so the issues described in https://github.com/google/blockly-keyboard-experimentation/issues/563 aren't fully mitigated with #9213 alone.

This PR essentially adds the same support for auto-closing as drop-down divs now have, and enables this functionality by default.

Note the drop-down div change: it was missed in #9123 that the API change for drop-down div's `show` function is actually API-breaking, so this updates that API to be properly backward compatible (and reverts one test change that makes use of it).

The `FocusManager` change actually corrects an implementation issue from #9123: not updating the tracked focus status before calling the callback can result in focus being inadvertently restored if the callback triggers returning focus due to a lost focus situation. This was wrong for drop-down divs, too, but it's harder to notice there because the dismissal of the drop-down div happens on a timer (which means there's sufficient time for `FocusManager`'s state to correct prior to attempting to return from the ephemeral focus state).

Demonstration of fixed behavior (since the inline number editor uses a widget div):

[Screen recording 2025-07-08 2.12.31 PM.webm](https://github.com/user-attachments/assets/7c3c7c3c-224c-48f4-b4af-bde86feecfa8)

### Test Coverage

New widget div tests have been added to verify the new parameter and auto-close functionality.

The `FocusManager` test was updated to account for the new, and correct, behavior around the internal tracked ephemeral focus state.

Note that some `tabindex` state has been clarified and cleaned up in the test index page and `FocusManager`. It's fine (and preferable) for ephemeral-used elements to always be focusable rather than making them dynamically so (which avoids state bleed across test runs which was happening one of the new tests).

https://github.com/google/blockly-keyboard-experimentation/pull/649 includes additional tests for validating widget behaviors.

### Documentation

No new documentation should be needed here--the API documentation changes should be sufficient.

One documentation update was made in `dropdowndiv.ts` that corrects the documentation parameter ordering.

### Additional Information

Nothing further to add.
2025-07-08 16:06:24 -07:00

370 lines
11 KiB
TypeScript

/**
* @license
* Copyright 2013 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.WidgetDiv
import * as browserEvents from './browser_events.js';
import * as common from './common.js';
import {Field} from './field.js';
import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js';
import * as dom from './utils/dom.js';
import type {Rect} from './utils/rect.js';
import type {Size} from './utils/size.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/** The object currently using this container. */
let owner: unknown = null;
/** The workspace associated with the owner currently using this container. */
let ownerWorkspace: WorkspaceSvg | null = null;
/** Optional cleanup function set by whichever object uses the widget. */
let dispose: (() => void) | null = null;
/** A class name representing the current owner's workspace container. */
const containerClassName = 'blocklyWidgetDiv';
/** A class name representing the current owner's workspace renderer. */
let rendererClassName = '';
/** A class name representing the current owner's workspace theme. */
let themeClassName = '';
/** The HTML container for popup overlays (e.g. editor widgets). */
let containerDiv: HTMLDivElement | null;
/** Callback to FocusManager to return ephemeral focus when the div closes. */
let returnEphemeralFocus: ReturnEphemeralFocus | null = null;
/**
* Returns the HTML container for editor widgets.
*
* @returns The editor widget container.
*/
export function getDiv(): HTMLDivElement | null {
return containerDiv;
}
/**
* Allows unit tests to reset the div. Do not use outside of tests.
*
* @param newDiv The new value for the DIV field.
* @internal
*/
export function testOnly_setDiv(newDiv: HTMLDivElement | null) {
containerDiv = newDiv;
if (newDiv === null) {
document.querySelector('.' + containerClassName)?.remove();
}
}
/**
* Create the widget div and inject it onto the page.
*/
export function createDom() {
const container = common.getParentContainer() || document.body;
const existingContainer = document.querySelector('div.' + containerClassName);
if (existingContainer) {
containerDiv = existingContainer as HTMLDivElement;
} else {
containerDiv = document.createElement('div');
containerDiv.className = containerClassName;
containerDiv.tabIndex = -1;
}
browserEvents.conditionalBind(
containerDiv,
'keydown',
null,
common.globalShortcutHandler,
);
container.appendChild(containerDiv);
}
/**
* Initialize and display the widget div. Close the old one if needed.
*
* @param newOwner The object that will be using this container.
* @param rtl Right-to-left (true) or left-to-right (false).
* @param newDispose Optional cleanup function to be run when the widget is
* closed.
* @param workspace The workspace associated with the widget owner.
* @param manageEphemeralFocus Whether ephemeral focus should be managed
* according to the widget div's lifetime. Note that if a false value is
* passed in here then callers should manage ephemeral focus directly
* otherwise focus may not properly restore when the widget closes. Defaults
* to true.
* @param autoCloseOnLostFocus Whether the widget should automatically hide if
* it loses DOM focus for any reason.
*/
export function show(
newOwner: unknown,
rtl: boolean,
newDispose: () => void,
workspace?: WorkspaceSvg | null,
manageEphemeralFocus: boolean = true,
autoCloseOnLostFocus: boolean = true,
) {
hide();
owner = newOwner;
dispose = newDispose;
const div = containerDiv;
if (!div) return;
div.style.direction = rtl ? 'rtl' : 'ltr';
div.style.display = 'block';
if (!workspace && newOwner instanceof Field) {
// For backward compatibility with plugin fields that do not provide a
// workspace to this function, attempt to derive it from the field.
workspace = (newOwner as Field).getSourceBlock()?.workspace as WorkspaceSvg;
}
ownerWorkspace = workspace ?? null;
const rendererWorkspace =
workspace ?? (common.getMainWorkspace() as WorkspaceSvg);
rendererClassName = rendererWorkspace.getRenderer().getClassName();
themeClassName = rendererWorkspace.getTheme().getClassName();
if (rendererClassName) {
dom.addClass(div, rendererClassName);
}
if (themeClassName) {
dom.addClass(div, themeClassName);
}
if (manageEphemeralFocus) {
const autoCloseCallback = autoCloseOnLostFocus
? (hasFocus: boolean) => {
// If focus is ever lost, close the widget.
if (!hasFocus) {
hide();
}
}
: null;
returnEphemeralFocus = getFocusManager().takeEphemeralFocus(
div,
autoCloseCallback,
);
}
}
/**
* Destroy the widget and hide the div.
*/
export function hide() {
if (!isVisible()) {
return;
}
owner = null;
const div = containerDiv;
if (!div) return;
(common.getMainWorkspace() as WorkspaceSvg).markFocused();
if (returnEphemeralFocus) {
returnEphemeralFocus();
returnEphemeralFocus = null;
}
// Content must be cleared after returning ephemeral focus since otherwise it
// may force focus changes which could desynchronize the focus manager and
// make it think the user directed focus away from the widget div (which will
// then notify it to not restore focus back to any previously focused node).
div.style.display = 'none';
div.style.left = '';
div.style.top = '';
if (dispose) {
dispose();
dispose = null;
}
div.textContent = '';
if (rendererClassName) {
dom.removeClass(div, rendererClassName);
rendererClassName = '';
}
if (themeClassName) {
dom.removeClass(div, themeClassName);
themeClassName = '';
}
}
/**
* Is the container visible?
*
* @returns True if visible.
*/
export function isVisible(): boolean {
return !!owner;
}
/**
* Destroy the widget and hide the div if it is being used by the specified
* object.
*
* @param oldOwner The object that was using this container.
*/
export function hideIfOwner(oldOwner: unknown) {
if (owner === oldOwner) {
hide();
}
}
/**
* Destroy the widget and hide the div if it is being used by an object in the
* specified workspace, or if it is used by an unknown workspace.
*
* @param workspace The workspace that was using this container.
*/
export function hideIfOwnerIsInWorkspace(workspace: WorkspaceSvg) {
let ownerIsInWorkspace = ownerWorkspace === null;
// Check if the given workspace is a parent workspace of the one containing
// our owner.
let currentWorkspace: WorkspaceSvg | null = workspace;
while (!ownerIsInWorkspace && currentWorkspace) {
if (currentWorkspace === workspace) {
ownerIsInWorkspace = true;
break;
}
currentWorkspace = workspace.options.parentWorkspace;
}
if (ownerIsInWorkspace) {
hide();
}
}
/**
* Set the widget div's position and height. This function does nothing clever:
* it will not ensure that your widget div ends up in the visible window.
*
* @param x Horizontal location (window coordinates, not body).
* @param y Vertical location (window coordinates, not body).
* @param height The height of the widget div (pixels).
*/
function positionInternal(x: number, y: number, height: number) {
containerDiv!.style.left = x + 'px';
containerDiv!.style.top = y + 'px';
containerDiv!.style.height = height + 'px';
}
/**
* Position the widget div based on an anchor rectangle.
* The widget should be placed adjacent to but not overlapping the anchor
* rectangle. The preferred position is directly below and aligned to the left
* (LTR) or right (RTL) side of the anchor.
*
* @param viewportBBox The bounding rectangle of the current viewport, in window
* coordinates.
* @param anchorBBox The bounding rectangle of the anchor, in window
* coordinates.
* @param widgetSize The size of the widget that is inside the widget div, in
* window coordinates.
* @param rtl Whether the workspace is in RTL mode. This determines horizontal
* alignment.
* @internal
*/
export function positionWithAnchor(
viewportBBox: Rect,
anchorBBox: Rect,
widgetSize: Size,
rtl: boolean,
) {
const y = calculateY(viewportBBox, anchorBBox, widgetSize);
const x = calculateX(viewportBBox, anchorBBox, widgetSize, rtl);
if (y < 0) {
positionInternal(x, 0, widgetSize.height + y);
} else {
positionInternal(x, y, widgetSize.height);
}
}
/**
* Calculate an x position (in window coordinates) such that the widget will not
* be offscreen on the right or left.
*
* @param viewportBBox The bounding rectangle of the current viewport, in window
* coordinates.
* @param anchorBBox The bounding rectangle of the anchor, in window
* coordinates.
* @param widgetSize The dimensions of the widget inside the widget div.
* @param rtl Whether the Blockly workspace is in RTL mode.
* @returns A valid x-coordinate for the top left corner of the widget div, in
* window coordinates.
*/
function calculateX(
viewportBBox: Rect,
anchorBBox: Rect,
widgetSize: Size,
rtl: boolean,
): number {
if (rtl) {
// Try to align the right side of the field and the right side of widget.
const widgetLeft = anchorBBox.right - widgetSize.width;
// Don't go offscreen left.
const x = Math.max(widgetLeft, viewportBBox.left);
// But really don't go offscreen right:
return Math.min(x, viewportBBox.right - widgetSize.width);
} else {
// Try to align the left side of the field and the left side of widget.
// Don't go offscreen right.
const x = Math.min(anchorBBox.left, viewportBBox.right - widgetSize.width);
// But left is more important, because that's where the text is.
return Math.max(x, viewportBBox.left);
}
}
/**
* Calculate a y position (in window coordinates) such that the widget will not
* be offscreen on the top or bottom.
*
* @param viewportBBox The bounding rectangle of the current viewport, in window
* coordinates.
* @param anchorBBox The bounding rectangle of the anchor, in window
* coordinates.
* @param widgetSize The dimensions of the widget inside the widget div.
* @returns A valid y-coordinate for the top left corner of the widget div, in
* window coordinates.
*/
function calculateY(
viewportBBox: Rect,
anchorBBox: Rect,
widgetSize: Size,
): number {
// Flip the widget vertically if off the bottom.
// The widget could go off the top of the window, but it would also go off
// the bottom. The window is just too small.
if (anchorBBox.bottom + widgetSize.height >= viewportBBox.bottom) {
// The bottom of the widget is at the top of the field.
return anchorBBox.top - widgetSize.height;
} else {
// The top of the widget is at the bottom of the field.
return anchorBBox.bottom;
}
}
/**
* Determine if the owner is a field for purposes of repositioning.
* We can't simply check `instanceof Field` as that would introduce a circular
* dependency.
*/
function isRepositionable(item: any): item is Field {
return !!item?.repositionForWindowResize;
}
/**
* Reposition the widget div if the owner of it says to.
* If the owner isn't a field, just give up and hide it.
*/
export function repositionForWindowResize(): void {
if (!isRepositionable(owner) || !owner.repositionForWindowResize()) {
// If the owner is not a Field, or if the owner returns false from the
// reposition method, we should hide the widget div. Otherwise, we'll assume
// the owner handled any needed resize.
hide();
}
}