diff --git a/core/dialog.ts b/core/dialog.ts index 374961323da..96631e9cbc7 100644 --- a/core/dialog.ts +++ b/core/dialog.ts @@ -33,6 +33,8 @@ const defaultPrompt = function ( defaultValue: string, callback: (result: string | null) => void, ) { + // NOTE TO DEVELOPER: Ephemeral focus doesn't need to be taken for the native + // window prompt since it prevents focus from changing while open. callback(window.prompt(message, defaultValue)); }; @@ -116,6 +118,11 @@ export function prompt( /** * Sets the function to be run when Blockly.dialog.prompt() is called. * + * **Important**: When overridding this, be aware that non-native prompt + * experiences may require managing ephemeral focus in FocusManager. This isn't + * needed for the native window prompt because it prevents focus from being + * changed while open. + * * @param promptFunction The function to be run, or undefined to restore the * default implementation. * @see Blockly.dialog.prompt diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index 0d259bc53d7..dcf8fa24ef7 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -15,6 +15,7 @@ import type {BlockSvg} from './block_svg.js'; import * as common from './common.js'; import type {Field} from './field.js'; +import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js'; import * as dom from './utils/dom.js'; import * as math from './utils/math.js'; import {Rect} from './utils/rect.js'; @@ -82,6 +83,9 @@ let owner: Field | null = null; /** Whether the dropdown was positioned to a field or the source block. */ let positionToField: boolean | null = null; +/** Callback to FocusManager to return ephemeral focus when the div closes. */ +let returnEphemeralFocus: ReturnEphemeralFocus | null = null; + /** * Dropdown bounds info object used to encapsulate sizing information about a * bounding element (bounding box and width/height). @@ -338,6 +342,8 @@ export function show( dom.addClass(div, renderedClassName); dom.addClass(div, themeClassName); + returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); + // When we change `translate` multiple times in close succession, // Chrome may choose to wait and apply them all at once. // Since we want the translation to initial X, Y to be immediate, @@ -623,6 +629,10 @@ export function hide() { animateOutTimer = setTimeout(function () { hideWithoutAnimation(); }, ANIMATION_TIME * 1000); + if (returnEphemeralFocus) { + returnEphemeralFocus(); + returnEphemeralFocus = null; + } if (onHide) { onHide(); onHide = null; @@ -638,6 +648,10 @@ export function hideWithoutAnimation() { clearTimeout(animateOutTimer); } + if (returnEphemeralFocus) { + returnEphemeralFocus(); + returnEphemeralFocus = null; + } if (onHide) { onHide(); onHide = null; diff --git a/core/field.ts b/core/field.ts index 725a2867d9e..a5e43a27665 100644 --- a/core/field.ts +++ b/core/field.ts @@ -25,6 +25,8 @@ import * as eventUtils from './events/utils.js'; import type {Input} from './inputs/input.js'; import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js'; import type {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js'; import type {IRegistrable} from './interfaces/i_registrable.js'; import {ISerializable} from './interfaces/i_serializable.js'; @@ -34,6 +36,7 @@ import type {KeyboardShortcut} from './shortcut_registry.js'; import * as Tooltip from './tooltip.js'; import type {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; +import * as idGenerator from './utils/idgenerator.js'; import * as parsing from './utils/parsing.js'; import {Rect} from './utils/rect.js'; import {Size} from './utils/size.js'; @@ -42,7 +45,7 @@ import {Svg} from './utils/svg.js'; import * as userAgent from './utils/useragent.js'; import * as utilsXml from './utils/xml.js'; import * as WidgetDiv from './widgetdiv.js'; -import type {WorkspaceSvg} from './workspace_svg.js'; +import {WorkspaceSvg} from './workspace_svg.js'; /** * A function that is called to validate changes to the field's value before @@ -72,7 +75,8 @@ export abstract class Field IASTNodeLocationWithBlock, IKeyboardAccessible, IRegistrable, - ISerializable + ISerializable, + IFocusableNode { /** * To overwrite the default value which is set in **Field**, directly update @@ -191,6 +195,9 @@ export abstract class Field */ SERIALIZABLE = false; + /** The unique ID of this field. */ + private id_: string | null = null; + /** * @param value The initial value of the field. * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by @@ -255,6 +262,7 @@ export abstract class Field throw Error('Field already bound to a block'); } this.sourceBlock_ = block; + this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; } /** @@ -298,7 +306,12 @@ export abstract class Field // Field has already been initialized once. return; } - this.fieldGroup_ = dom.createSvgElement(Svg.G, {}); + const id = this.id_; + if (!id) throw new Error('Expected ID to be defined prior to init.'); + this.fieldGroup_ = dom.createSvgElement(Svg.G, { + 'tabindex': '-1', + 'id': id, + }); if (!this.isVisible()) { this.fieldGroup_.style.display = 'none'; } @@ -1401,6 +1414,29 @@ export abstract class Field } } + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + if (!this.fieldGroup_) { + throw Error('This field currently has no representative DOM element.'); + } + return this.fieldGroup_; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + return block.workspace as WorkspaceSvg; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + /** * Subclasses should reimplement this method to construct their Field * subclass from a JSON arg object. diff --git a/core/widgetdiv.ts b/core/widgetdiv.ts index f167b6cf04d..cb006160455 100644 --- a/core/widgetdiv.ts +++ b/core/widgetdiv.ts @@ -8,6 +8,7 @@ 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'; @@ -34,6 +35,9 @@ 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. * @@ -110,6 +114,7 @@ export function show( if (themeClassName) { dom.addClass(div, themeClassName); } + returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); } /** @@ -126,8 +131,14 @@ export function hide() { div.style.display = 'none'; div.style.left = ''; div.style.top = ''; - if (dispose) dispose(); - dispose = null; + if (returnEphemeralFocus) { + returnEphemeralFocus(); + returnEphemeralFocus = null; + } + if (dispose) { + dispose(); + dispose = null; + } div.textContent = ''; if (rendererClassName) { diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 921fb72ecc7..ee526bf8b6b 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -2709,6 +2709,18 @@ export class WorkspaceSvg } } + const fieldIndicatorIndex = id.indexOf('_field_'); + if (fieldIndicatorIndex !== -1) { + const blockId = id.substring(0, fieldIndicatorIndex); + const block = this.getBlockById(blockId); + if (block) { + for (const field of block.getFields()) { + if (field.getFocusableElement().id === id) return field; + } + } + return null; + } + return this.getBlockById(id) as IFocusableNode; }