mirror of
https://github.com/google/blockly.git
synced 2026-01-09 01:50: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 #8922 Fixes #8929 Fixes part of #8771 ### Proposed Changes This PR introduces support for fields to be focusable (and thus navigable with keyboard navigation when paired with downstream changes to `LineCursor` and the keyboard navigation plugin). This is a largely isolated change in how it fundamentally works: - `Field` was updated to become an `IFocusableNode`. Note that it uses a specific string-based ID schema in order to ensure that it can be properly linked back to its unique block (which helps make the search for the field in `WorkspaceSvg` a bit more efficient). This could be done with a globally unique ID, instead, but all fields would need to be searched vs. just those for the field's parent block. - The drop-down and widget divs have been updated to manage ephemeral focus with `FocusManager` when they're open for non-system dialogs (ephemeral focus isn't needed for system dialogs/prompts since those already take/restore focus in a native way that `FocusManager` will respond to--this may require future work, however, if the restoration causes unexpected behavior for users). This approach was done due to a suggestion from @maribethb as the alternative would be a more complicated breaking change (forcing `Field` subclasses to properly manage ephemeral focus). It may still be the case that certain cases will need to do so, but widget and drop-down divs seem to address the majority of possibilities. **Important**: `Input`s are not explicitly being supported here. As far as I can tell, we can't run into a case where `LineCursor` tries to set an input node, though perhaps I simply haven't come across this case. Supporting `Fields` and `Connections` (per #8928) seems to cover the main needed cases, though making `Input`s focusable may be a future requirement. ### Reason for Changes This is part of an ongoing effort to ensure key components of Blockly are focusable so that they can be keyboard-navigable (with other needed changes yet both in Core Blockly and the keyboard navigation plugin). Note that #8929 isn't broadly addressed since making widget & drop down divs manage ephemeral focus directly addresses a large class of cases. Additional cases may arise where a plugin or Blockly integration may require additional effort to make keyboard navigation work for their field--this may be best addressed with documentation and guidance. ### Test Coverage No new tests have been added. It's certainly possible to add unit tests for the focusable configurations being introduced in this PR, but it may not be highly beneficial. It's largely assumed that the individual implementations should work due to a highly tested FocusManager, and it may be the case that the interactions of the components working together is far more important to verify (that is, the end user flows). The latter is planned to be tackled as part of #8915. ### Documentation No new documentation is planned, however it may be prudent to update the field documentation in the future to explain how to utilize ephemeral focus when specifically building compatibility for keyboard navigation. ### Additional Information This includes changes that have been pulled from #8875.
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2013 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
// Former goog.module ID: Blockly.WidgetDiv
|
|
|
|
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;
|
|
|
|
if (document.querySelector('.' + containerClassName)) {
|
|
containerDiv = document.querySelector('.' + containerClassName);
|
|
} else {
|
|
containerDiv = document.createElement('div') as HTMLDivElement;
|
|
containerDiv.className = containerClassName;
|
|
}
|
|
|
|
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.
|
|
*/
|
|
export function show(
|
|
newOwner: unknown,
|
|
rtl: boolean,
|
|
newDispose: () => void,
|
|
workspace?: WorkspaceSvg | null,
|
|
) {
|
|
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);
|
|
}
|
|
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;
|
|
div.style.display = 'none';
|
|
div.style.left = '';
|
|
div.style.top = '';
|
|
if (returnEphemeralFocus) {
|
|
returnEphemeralFocus();
|
|
returnEphemeralFocus = null;
|
|
}
|
|
if (dispose) {
|
|
dispose();
|
|
dispose = null;
|
|
}
|
|
div.textContent = '';
|
|
|
|
if (rendererClassName) {
|
|
dom.removeClass(div, rendererClassName);
|
|
rendererClassName = '';
|
|
}
|
|
if (themeClassName) {
|
|
dom.removeClass(div, themeClassName);
|
|
themeClassName = '';
|
|
}
|
|
(common.getMainWorkspace() as WorkspaceSvg).markFocused();
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|