mirror of
https://github.com/google/blockly.git
synced 2026-01-07 09:00:11 +01:00
## 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 introduces support in `FocusManager` to receive feedback on when an ephemerally focused element entirely loses focus (that is, neither it nor any of its descendants have focus). This also introduces a behavior change for drop-down divs using the previously mentioned functionality to automatically close themselves when they lose focus for any reason (e.g. clicking outside of the div or tab navigating away from it). Finally, and **importantly**, this adds a case where ephemeral focus does _not_ automatically return to the previously focused node: when focus is lost to the ephemerally focused element's tree and isn't instead put on another focused node. ### Reason for Changes Ultimately, focus is probably the best proxy for cases when a drop-down div ought to no longer be open. However, tracking focus only within the scope of the drop-down div utility is rather difficult since a lot of the same problems that `FocusManager` handles also occur here (with regards to both descendants and outside elements receiving focus). It made more sense to expand `FocusManager`'s ephemeral focus support: - It was easier to implement this `FocusManager` and in a way that's much more robust (since it's leveraging existing event handlers). - Using `FocusManager` trivialized the solution for drop-down divs. - There could be other use cases where custom ephemeral focus uses might benefit from knowing when they lose focus. This new support is enabled by default for all drop-down divs, but can be disabled by callers if they wish to revert to the previous behavior of not auto-closing. The change for whether to restore ephemeral focus was needed to fix a drawback that arises from the automatic returning of ephemeral focus introduced in this PR: when a user clicks out of an open drop-down menu it will restore focus back to the node that held focus prior to taking ephemeral focus (since it properly hides the drop-down div and restores focus). This creates awkward behavior issues for both mouse and keyboard users: - For mouse: trying to open a drop-down outside of Blockly will automatically close the drop-down when the Blockly drop-down finishes closing (since focus is stolen back away from the thing the user clicked on). - For keyboard: tab navigating out of Blockly tries to force focus back to Blockly. **New in v2 of this PR**: Commit0363d67c18is the main one that prevents #9203 from being reintroduced by ensuring that widget div only clears its contents after ephemeral focus has returned. This was missed in the first audit since it wasn't clear that this line, in particular, can cause a div with focus to be removed and thus focus lost:dfd565957b/core/widgetdiv.ts (L156)### Test Coverage New tests have been added for both the drop-down div and `FocusManager` components, and have been verified as failing without the new behaviors in place. There may be other edge cases worth testing for `FocusManager` in particular, but the tests introduced in this PR seem to cover the most important cases. **New in v2 of this PR**: A test was added to validate that widget div now clears its contents only after ephemeral focus has returned to avoid the desyncing scenario that led to #9203. This test has been verified to fail without the fix. There are also a few new tests being added in the keyboard navigation plugin repository that also validate this behavior at a higher level (see https://github.com/google/blockly-keyboard-experimentation/pull/649). Demonstration of the new behavior: [Screen recording 2025-07-01 6.28.37 PM.webm](https://github.com/user-attachments/assets/7af29fed-1ba1-4828-a6cd-65bb94509e72) ### Documentation No new documentation changes seem needed beyond the code documentation updates. ### Additional Information It's also possible to change the automatic restoration behavior to be conditional instead of always assuming focus shouldn't be reset if focus leaves the ephemeral element, but that's probably a better change if there's an actual user issue discovered with this approach. This was originally introduced in #9175 but it was reverted in #9204 due to #9203.
356 lines
11 KiB
TypeScript
356 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.
|
|
*/
|
|
export function show(
|
|
newOwner: unknown,
|
|
rtl: boolean,
|
|
newDispose: () => void,
|
|
workspace?: WorkspaceSvg | null,
|
|
manageEphemeralFocus: 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) {
|
|
returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|