feat: Add keyboard shortcut to perform an action on the currently focused element (#9673)

* feat: Add keyboard shortcut to perform an action on the currently focused element

* test: Add tests

* chore: Add example of shortcut formats

* fix: Don't show toast if shortcut doesn't exist

* chore: Clarify use of Zelos in tests

* fix: Skip help hint toast until shortcut is added
This commit is contained in:
Aaron Dodson
2026-04-08 13:14:04 -07:00
committed by GitHub
parent 5bc04b6435
commit 41319baf5d
18 changed files with 544 additions and 20 deletions
+30
View File
@@ -35,6 +35,7 @@ import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import {FieldLabel} from './field_label.js';
import {getFocusManager} from './focus_manager.js';
import * as hints from './hints.js';
import {IconType} from './icons/icon_types.js';
import {MutatorIcon} from './icons/mutator_icon.js';
import {WarningIcon} from './icons/warning_icon.js';
@@ -52,6 +53,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import {IIcon} from './interfaces/i_icon.js';
import * as internalConstants from './internal_constants.js';
import {KeyboardMover} from './keyboard_nav/keyboard_mover.js';
import {Msg} from './msg.js';
import * as renderManagement from './render_management.js';
import {RenderedConnection} from './rendered_connection.js';
@@ -1903,6 +1905,34 @@ export class BlockSvg
return true;
}
/**
* Handles the user acting on this block via keyboard navigation.
* If this block is in the flyout, a new copy is spawned in move mode on the
* main workspace. If this block has a single full-block field, that field
* will be focused. Otherwise, this is a no-op.
*/
performAction() {
if (this.workspace.isFlyout) {
KeyboardMover.mover.startMove(this);
return;
} else if (this.isSimpleReporter()) {
for (const input of this.inputList) {
for (const field of input.fieldRow) {
if (field.isClickable() && field.isFullBlockField()) {
field.showEditor();
return;
}
}
}
}
if (this.workspace.getNavigator().getFirstChild(this)) {
hints.showBlockNavigationHint(this.workspace);
} else {
hints.showHelpHint(this.workspace);
}
}
/**
* Returns a set of all of the parent blocks of the given block.
*
@@ -6,6 +6,7 @@
import type {BlocklyOptions} from '../blockly_options.js';
import {Abstract as AbstractEvent} from '../events/events_abstract.js';
import {getFocusManager} from '../focus_manager.js';
import {KeyboardMover} from '../keyboard_nav/keyboard_mover.js';
import {Options} from '../options.js';
import {Coordinate} from '../utils/coordinate.js';
@@ -287,4 +288,12 @@ export class MiniWorkspaceBubble extends Bubble {
'monkey-patched in by blockly.ts',
);
}
/**
* Handles the user acting on this bubble via keyboard navigation by focusing
* the mutator workspace.
*/
performAction() {
getFocusManager().focusTree(this.getWorkspace());
}
}
@@ -279,6 +279,14 @@ export class TextInputBubble extends Bubble {
getEditor() {
return this.editor;
}
/**
* Handles the user acting on this bubble via keyboard navigation by focusing
* the comment editor.
*/
performAction() {
getFocusManager().focusNode(this.getEditor());
}
}
Css.register(`
@@ -358,4 +358,13 @@ export class RenderedWorkspaceComment
canBeFocused(): boolean {
return true;
}
/**
* Handles the user acting on this comment via keyboard navigation.
* Expands the comment and focuses its editor.
*/
performAction() {
this.setCollapsed(false);
getFocusManager().focusNode(this.getEditorFocusableNode());
}
}
+8
View File
@@ -1488,6 +1488,14 @@ export abstract class Field<T = any>
return true;
}
/**
* Handles the user acting on this field via keyboard navigation.
* Shows and focuses the field editor.
*/
performAction() {
this.showEditor();
}
/**
* Subclasses should reimplement this method to construct their Field
* subclass from a JSON arg object.
+13
View File
@@ -421,6 +421,19 @@ export class FlyoutButton
getId() {
return this.id;
}
/**
* Handles the user acting on this button via keyboard navigation.
* Invokes the click handler callback.
*/
performAction(): void {
if (!this.isFlyoutLabel) {
const callback = this.targetWorkspace.getButtonCallback(this.callbackKey);
if (callback) {
callback(this);
}
}
}
}
/** CSS for buttons and labels. See css.js for use. */
+40
View File
@@ -6,11 +6,15 @@
import {Msg} from './msg.js';
import {Toast} from './toast.js';
import {getShortActionShortcut} from './utils/shortcut_formatting.js';
import * as userAgent from './utils/useragent.js';
import type {WorkspaceSvg} from './workspace_svg.js';
const unconstrainedMoveHintId = 'unconstrainedMoveHint';
const constrainedMoveHintId = 'constrainedMoveHint';
const helpHintId = 'helpHint';
const blockNavigationHintId = 'blockNavigationHint';
const workspaceNavigationHintId = 'workspaceNavigationHint';
/**
* Nudge the user to use unconstrained movement.
@@ -62,3 +66,39 @@ export function clearMoveHints(workspace: WorkspaceSvg) {
Toast.hide(workspace, constrainedMoveHintId);
Toast.hide(workspace, unconstrainedMoveHintId);
}
/**
* Nudge the user to open the help.
*
* @param workspace The workspace.
*/
export function showHelpHint(workspace: WorkspaceSvg) {
const shortcut = getShortActionShortcut('list_shortcuts');
if (!shortcut) return;
const message = Msg['HELP_PROMPT'].replace('%1', shortcut);
const id = helpHintId;
Toast.show(workspace, {message, id});
}
/**
* Tell the user how to navigate inside blocks.
*
* @param workspace The workspace.
*/
export function showBlockNavigationHint(workspace: WorkspaceSvg) {
const message = Msg['KEYBOARD_NAV_BLOCK_NAVIGATION_HINT'];
const id = blockNavigationHintId;
Toast.show(workspace, {message, id});
}
/**
* Tell the user how to navigate inside the workspace.
*
* @param workspace The workspace.
*/
export function showWorkspaceNavigationHint(workspace: WorkspaceSvg) {
const message = Msg['KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT'];
const id = workspaceNavigationHintId;
Toast.show(workspace, {message, id});
}
+18
View File
@@ -7,10 +7,12 @@
import type {Block} from '../block.js';
import type {BlockSvg} from '../block_svg.js';
import * as browserEvents from '../browser_events.js';
import {getFocusManager} from '../focus_manager.js';
import type {IContextMenu} from '../interfaces/i_contextmenu.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import {hasBubble} from '../interfaces/i_has_bubble.js';
import type {IIcon} from '../interfaces/i_icon.js';
import * as renderManagement from '../render_management.js';
import * as tooltip from '../tooltip.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
@@ -189,6 +191,22 @@ export abstract class Icon implements IIcon, IContextMenu {
return true;
}
/**
* Handles the user acting on this icon via keyboard navigation.
* Performs the same action as a click would, and focuses this icon's bubble
* if it has one.
*/
performAction() {
this.onClick();
renderManagement.finishQueuedRenders().then(() => {
if (hasBubble(this) && this.bubbleIsVisible()) {
const bubble = this.getBubble();
if (!bubble) return;
getFocusManager().focusNode(bubble);
}
});
}
/**
* Returns the block that this icon is attached to.
*
@@ -99,6 +99,13 @@ export interface IFocusableNode {
* @returns Whether this node can be focused by FocusManager.
*/
canBeFocused(): boolean;
/**
* Optional method invoked when this node has focus and the user acts on it by
* pressing Enter or Space. Behavior should generally be similar to the node
* being clicked on.
*/
performAction?(): void;
}
/**
@@ -102,7 +102,7 @@ export class KeyboardMover {
* @param event The keyboard event that triggered this move.
* @returns True iff a move has successfully begun.
*/
startMove(draggable: IDraggable, event: KeyboardEvent) {
startMove(draggable: IDraggable, event?: KeyboardEvent) {
if (!this.canMove(draggable) || this.isMoving()) return false;
const DraggerClass = registry.getClassFromOptions(
+25
View File
@@ -59,6 +59,7 @@ export enum names {
NEXT_STACK = 'next_stack',
PREVIOUS_STACK = 'previous_stack',
INFORMATION = 'information',
PERFORM_ACTION = 'perform_action',
}
/**
@@ -846,6 +847,29 @@ export function registerStackNavigation() {
ShortcutRegistry.registry.register(previousStackShortcut);
}
/**
* Registers keyboard shortcut to perform an action on the focused element.
*/
export function registerPerformAction() {
const performActionShortcut: KeyboardShortcut = {
name: names.PERFORM_ACTION,
preconditionFn: (workspace) => !workspace.isDragging(),
callback: (_workspace, e) => {
keyboardNavigationController.setIsActive(true);
const focusedNode = getFocusManager().getFocusedNode();
if (focusedNode && 'performAction' in focusedNode) {
e.preventDefault();
focusedNode.performAction?.();
return true;
}
return false;
},
keyCodes: [KeyCodes.ENTER, KeyCodes.SPACE],
allowCollision: true,
};
ShortcutRegistry.registry.register(performActionShortcut);
}
/**
* Registers all default keyboard shortcut item. This should be called once per
* instance of KeyboardShortcutRegistry.
@@ -874,6 +898,7 @@ export function registerKeyboardNavigationShortcuts() {
registerArrowNavigation();
registerDisconnectBlock();
registerStackNavigation();
registerPerformAction();
}
/**
@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/
import {Msg} from '../msg.js';
import {ShortcutRegistry} from '../shortcut_registry.js';
import * as userAgent from './useragent.js';
/**
* Find the primary shortcut for this platform and return it as single string
* in a short user facing format.
*
* @internal
* @param action The action name, e.g. "cut".
* @returns The formatted shortcut.
*/
export function getShortActionShortcut(action: string): string {
const shortcuts = getActionShortcutsAsKeys(action, shortModifierNames);
if (shortcuts.length) {
const parts = shortcuts[0];
return parts.join(userAgent.APPLE ? ' ' : ' + ');
}
return '';
}
/**
* Find the relevant shortcuts for the given action for the current platform.
* Keys are returned in a long user facing format, e.g. "Command ⌘ Option ⌥ C"
*
* @internal
* @param action The action name, e.g. "cut".
* @returns The formatted shortcuts as individual keys.
*/
export function getLongActionShortcutsAsKeys(action: string): string[][] {
return getActionShortcutsAsKeys(action, longModifierNames);
}
const longModifierNames: Record<string, string> = {
'Control': Msg['CONTROL_KEY'],
'Meta': Msg['COMMAND_KEY'],
'Alt': userAgent.APPLE ? Msg['OPTION_KEY'] : Msg['ALT_KEY'],
};
const shortModifierNames: Record<string, string> = {
'Control': Msg['CONTROL_KEY'],
'Meta': '⌘',
'Alt': userAgent.APPLE ? '⌥' : Msg['ALT_KEY'],
};
/**
* Find the relevant shortcuts for the given action for the current platform.
* Keys are returned in a short user facing format, e.g. "⌘ ⌥ C"
*
* This could be considerably simpler if we only bound shortcuts relevant to the
* current platform or tagged them with a platform.
*
* @param action The action name, e.g. "cut".
* @param modifierNames The names to use for the Meta/Control/Alt modifiers.
* @returns The formatted shortcuts.
*/
function getActionShortcutsAsKeys(
action: string,
modifierNames: Record<string, string>,
): string[][] {
const shortcuts = ShortcutRegistry.registry.getKeyCodesByShortcutName(action);
if (shortcuts.length === 0) {
return [];
}
// See ShortcutRegistry.createSerializedKey for the starting format.
const shortcutsAsParts = shortcuts.map((shortcut) => shortcut.split('+'));
// Prefer e.g. Cmd+Shift to Shift+Cmd.
shortcutsAsParts.forEach((s) =>
s.sort((a, b) => {
const aValue = modifierOrder(a);
const bValue = modifierOrder(b);
return aValue - bValue;
}),
);
// Needed to prefer Command to Option where we've bound Alt.
shortcutsAsParts.sort((a, b) => {
const aValue = a.includes('Meta') ? 1 : 0;
const bValue = b.includes('Meta') ? 1 : 0;
return bValue - aValue;
});
let currentPlatform = shortcutsAsParts.filter((shortcut) => {
const isMacShortcut = shortcut.includes('Meta');
return isMacShortcut === userAgent.APPLE;
});
currentPlatform =
currentPlatform.length === 0 ? shortcutsAsParts : currentPlatform;
// Prefer simpler shortcuts. This promotes Ctrl+Y for redo.
currentPlatform.sort((a, b) => {
return a.length - b.length;
});
// If there are modifiers return only one shortcut on the assumption they are
// intended for different platforms. Otherwise assume they are alternatives.
const hasModifiers = currentPlatform.some((shortcut) =>
shortcut.some(
(key) => 'Meta' === key || 'Alt' === key || 'Control' === key,
),
);
const chosen = hasModifiers ? [currentPlatform[0]] : currentPlatform;
return chosen.map((shortcut) => {
return shortcut
.map((maybeNumeric) =>
Number.isFinite(+maybeNumeric)
? String.fromCharCode(+maybeNumeric)
: maybeNumeric,
)
.map((k) => upperCaseFirst(modifierNames[k] ?? k));
});
}
/**
* Convert the first character to uppercase.
*
* @param str String.
* @returns The string in title case.
*/
function upperCaseFirst(str: string) {
return str.charAt(0).toUpperCase() + str.substring(1);
}
/**
* Preferred listing order of untranslated modifiers.
*/
const modifierOrdering: string[] = ['Meta', 'Control', 'Alt', 'Shift'];
function modifierOrder(key: string): number {
const order = modifierOrdering.indexOf(key);
// Regular keys at the end.
return order === -1 ? Number.MAX_VALUE : order;
}
+2
View File
@@ -84,3 +84,5 @@ export const IPHONE: boolean = isIPhone;
export const MAC: boolean = isMac;
export const MOBILE: boolean = isMobile;
export const APPLE: boolean = MAC || IPAD || IPHONE;
+9
View File
@@ -45,6 +45,7 @@ import type {FlyoutButton} from './flyout_button.js';
import {getFocusManager} from './focus_manager.js';
import {Gesture} from './gesture.js';
import {Grid} from './grid.js';
import * as hints from './hints.js';
import {MutatorIcon} from './icons/mutator_icon.js';
import {isAutoHideable} from './interfaces/i_autohideable.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
@@ -2852,6 +2853,14 @@ export class WorkspaceSvg
}
}
/**
* Handles the user acting on this workspace via keyboard navigation by
* prompting them to use the arrow keys (instead of Enter) to navigate.
*/
performAction() {
hints.showWorkspaceNavigationHint(this);
}
/**
* Returns an object responsible for coordinating movement of focus between
* items on this workspace in response to keyboard navigation commands.
+3 -1
View File
@@ -425,5 +425,7 @@
"WORKSPACE_CONTENTS_BLOCKS_ONE": "One stack of blocks%2 in workspace.",
"WORKSPACE_CONTENTS_BLOCKS_ZERO": "No blocks%2 in workspace.",
"WORKSPACE_CONTENTS_COMMENTS_MANY": " and %1 comments",
"WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment"
"WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment",
"KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Use the right arrow key to navigate inside of blocks",
"KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Use the arrow keys to navigate"
}
+3 -1
View File
@@ -432,5 +432,7 @@
"WORKSPACE_CONTENTS_BLOCKS_ONE": "ARIA live region message announcing there is one stack of blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'One stack of blocks in workspace.'\n* 'One stack of blocks and 1 comment in workspace.'",
"WORKSPACE_CONTENTS_BLOCKS_ZERO": "ARIA live region message announcing there are no blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'No blocks in workspace.'\n* 'No blocks and 3 comments in workspace.'",
"WORKSPACE_CONTENTS_COMMENTS_MANY": "ARIA live region phrase appended when there are multiple workspace comments. \n\nParameters:\n* %1 - the number of comments (integer greater than 1)",
"WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment."
"WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment.",
"KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Message shown when a user presses Enter with a navigable block focused.",
"KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Message shown when a user presses Enter with the workspace focused."
}
+6
View File
@@ -1718,3 +1718,9 @@ Blockly.Msg.WORKSPACE_CONTENTS_COMMENTS_MANY = ' and %1 comments';
/** @type {string} */
/// ARIA live region phrase appended when there is exactly one workspace comment.
Blockly.Msg.WORKSPACE_CONTENTS_COMMENTS_ONE = ' and one comment';
/** @type {string} */
/// Message shown when a user presses Enter with a navigable block focused.
Blockly.Msg.KEYBOARD_NAV_BLOCK_NAVIGATION_HINT = 'Use the right arrow key to navigate inside of blocks';
/** @type {string} */
/// Message shown when a user presses Enter with the workspace focused.
Blockly.Msg.KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT = 'Use the arrow keys to navigate';
@@ -20,7 +20,8 @@ suite('Keyboard Shortcut Items', function () {
setup(function () {
sharedTestSetup.call(this);
const toolbox = document.getElementById('toolbox-test');
this.workspace = Blockly.inject('blocklyDiv', {toolbox});
// Zelos has full-block fields, which we want to exercise in tests.
this.workspace = Blockly.inject('blocklyDiv', {toolbox, renderer: 'zelos'});
this.injectionDiv = this.workspace.getInjectionDiv();
Blockly.ContextMenuRegistry.registry.reset();
Blockly.ContextMenuItems.registerDefaultOptions();
@@ -633,22 +634,6 @@ suite('Keyboard Shortcut Items', function () {
});
suite('Focus Toolbox (T)', function () {
setup(function () {
Blockly.defineBlocksWithJsonArray([
{
'type': 'basic_block',
'message0': '%1',
'args0': [
{
'type': 'field_input',
'name': 'TEXT',
'text': 'default',
},
],
},
]);
});
test('Does not change focus when toolbox item is already focused', function () {
const item = this.workspace.getToolbox().getToolboxItems()[1];
Blockly.getFocusManager().focusNode(item);
@@ -1033,4 +1018,217 @@ suite('Keyboard Shortcut Items', function () {
);
});
});
suite('Perform Action (Enter)', function () {
test('Shows a toast with navigation hints on the workspace', function () {
const toastSpy = sinon.spy(Blockly.Toast, 'show');
Blockly.getFocusManager().focusNode(this.workspace);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
sinon.assert.calledWith(toastSpy, this.workspace, {
id: 'workspaceNavigationHint',
message: Blockly.Msg['KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT'],
});
toastSpy.restore();
});
test('Inserts blocks from the flyout in move mode', function () {
this.workspace.getToolbox().selectItemByPosition(0);
const block = this.workspace
.getNavigator()
.getFirstChild(this.workspace.getFlyout().getWorkspace());
assert.instanceOf(block, Blockly.BlockSvg);
Blockly.getFocusManager().focusNode(block);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
const movingBlock = Blockly.getFocusManager().getFocusedNode();
assert.notEqual(block, movingBlock);
assert.instanceOf(movingBlock, Blockly.BlockSvg);
assert.isTrue(movingBlock.isDragging());
assert.isFalse(movingBlock.workspace.isFlyout);
Blockly.KeyboardMover.mover.abortMove();
});
test('Shows a toast with navigation hints for navigable blocks', function () {
const toastSpy = sinon.spy(Blockly.Toast, 'show');
const block = this.workspace.newBlock('controls_if');
block.initSvg();
block.render();
Blockly.getFocusManager().focusNode(block);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
sinon.assert.calledWith(toastSpy, this.workspace, {
id: 'blockNavigationHint',
message: Blockly.Msg['KEYBOARD_NAV_BLOCK_NAVIGATION_HINT'],
});
toastSpy.restore();
});
// Reenable this tests once the shortcut listing shortcut has been added.
test.skip('Shows a toast with instructions to view help for non-navigable blocks', function () {
const toastSpy = sinon.spy(Blockly.Toast, 'show');
const block = this.workspace.newBlock('test_align_dummy_right');
block.initSvg();
block.render();
Blockly.getFocusManager().focusNode(block);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
sinon.assert.calledWith(toastSpy, this.workspace, {
id: 'helpHint',
message: Blockly.Msg['HELP_PROMPT'].replace('%1', ''),
});
toastSpy.restore();
});
test('Focuses field editor for blocks with full-block fields', function () {
const block = this.workspace.newBlock('math_number');
block.initSvg();
block.render();
Blockly.getFocusManager().focusNode(block);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
const field = block.getField('NUM');
assert.isTrue(Blockly.WidgetDiv.isVisible());
assert.isTrue(field.isBeingEdited_);
});
test('Focuses field editor for fields', function () {
const block = this.workspace.newBlock('logic_compare');
block.initSvg();
block.render();
const field = block.getField('OP');
Blockly.getFocusManager().focusNode(field);
assert.isFalse(Blockly.DropDownDiv.isVisible());
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
assert.isTrue(Blockly.DropDownDiv.isVisible());
});
test('Expands and focuses workspace comment editors', function () {
const comment = this.workspace.newComment();
comment.setCollapsed(true);
Blockly.getFocusManager().focusNode(comment);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
assert.strictEqual(
Blockly.getFocusManager().getFocusedNode(),
comment.getEditorFocusableNode(),
);
assert.isFalse(comment.view.isCollapsed());
});
test('Focuses mutator workspace for mutator bubble', async function () {
const block = this.workspace.newBlock('controls_if');
block.initSvg();
block.render();
const icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE);
await icon.setBubbleVisible(true);
Blockly.getFocusManager().focusNode(icon.getBubble());
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
assert.strictEqual(
Blockly.getFocusManager().getFocusedTree(),
icon.getWorkspace(),
);
});
test('Focuses comment editor for block comment bubble', async function () {
const block = this.workspace.newBlock('controls_if');
block.initSvg();
block.render();
block.setCommentText('Hello');
const icon = block.getIcon(Blockly.icons.CommentIcon.TYPE);
await icon.setBubbleVisible(true);
Blockly.getFocusManager().focusNode(icon.getBubble());
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
assert.strictEqual(
Blockly.getFocusManager().getFocusedNode(),
icon.getBubble().getEditor(),
);
});
test('Focuses bubble for icons', async function () {
const block = this.workspace.newBlock('controls_if');
block.initSvg();
block.render();
block.setCommentText('Hello world');
block.setWarningText('Danger!');
const iconTypes = [
Blockly.icons.CommentIcon.TYPE,
Blockly.icons.WarningIcon.TYPE,
Blockly.icons.MutatorIcon.TYPE,
];
for (const iconType of iconTypes) {
const icon = block.getIcon(iconType);
Blockly.getFocusManager().focusNode(icon);
const bubbleShown = new Promise((resolve) => {
this.workspace.addChangeListener((event) => {
if (event.type === Blockly.Events.BUBBLE_OPEN) {
resolve();
}
});
});
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
this.clock.tick(100);
await bubbleShown;
assert.strictEqual(
Blockly.getFocusManager().getFocusedNode(),
icon.getBubble(),
);
}
});
test('Triggers flyout button actions', function () {
const toolbox = this.workspace.getToolbox();
toolbox.selectItemByPosition(3);
const button = this.workspace.getFlyout().getContents()[0].getElement();
assert.instanceOf(button, Blockly.FlyoutButton);
Blockly.getFocusManager().focusNode(button);
const oldCallback = this.workspace.getButtonCallback('CREATE_VARIABLE');
let called = false;
this.workspace.registerButtonCallback('CREATE_VARIABLE', () => {
called = true;
});
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
this.workspace.getInjectionDiv().dispatchEvent(event);
assert.isTrue(called);
this.workspace.registerButtonCallback('CREATE_VARIABLE', oldCallback);
});
});
});