From 0ef22eadac0ab168c7777abf02ef0a28cb367908 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 19 Mar 2026 11:29:30 -0700 Subject: [PATCH 1/4] feat: Add keyboard shortcut to perform an action on the currently focused element --- packages/blockly/core/block_svg.ts | 30 ++++ .../core/bubbles/mini_workspace_bubble.ts | 9 ++ .../blockly/core/bubbles/textinput_bubble.ts | 8 + .../comments/rendered_workspace_comment.ts | 9 ++ packages/blockly/core/field.ts | 8 + packages/blockly/core/flyout_button.ts | 13 ++ packages/blockly/core/hints.ts | 38 +++++ packages/blockly/core/icons/icon.ts | 18 +++ .../core/interfaces/i_focusable_node.ts | 7 + .../core/keyboard_nav/keyboard_mover.ts | 2 +- packages/blockly/core/shortcut_items.ts | 25 ++++ .../blockly/core/utils/shortcut_formatting.ts | 138 ++++++++++++++++++ packages/blockly/core/utils/useragent.ts | 2 + packages/blockly/core/workspace_svg.ts | 9 ++ packages/blockly/msg/json/en.json | 6 +- packages/blockly/msg/json/qqq.json | 4 +- packages/blockly/msg/messages.js | 8 +- 17 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 packages/blockly/core/utils/shortcut_formatting.ts diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 666436c1eac..952492b7141 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -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'; @@ -1906,6 +1908,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. * diff --git a/packages/blockly/core/bubbles/mini_workspace_bubble.ts b/packages/blockly/core/bubbles/mini_workspace_bubble.ts index 00a50dc30d1..956787de464 100644 --- a/packages/blockly/core/bubbles/mini_workspace_bubble.ts +++ b/packages/blockly/core/bubbles/mini_workspace_bubble.ts @@ -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()); + } } diff --git a/packages/blockly/core/bubbles/textinput_bubble.ts b/packages/blockly/core/bubbles/textinput_bubble.ts index 0bad5fabce6..3ede92b7497 100644 --- a/packages/blockly/core/bubbles/textinput_bubble.ts +++ b/packages/blockly/core/bubbles/textinput_bubble.ts @@ -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(` diff --git a/packages/blockly/core/comments/rendered_workspace_comment.ts b/packages/blockly/core/comments/rendered_workspace_comment.ts index b422d252d94..5edafd5fe8a 100644 --- a/packages/blockly/core/comments/rendered_workspace_comment.ts +++ b/packages/blockly/core/comments/rendered_workspace_comment.ts @@ -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()); + } } diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index e025efab709..77d7535aad1 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -1395,6 +1395,14 @@ export abstract class Field 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. diff --git a/packages/blockly/core/flyout_button.ts b/packages/blockly/core/flyout_button.ts index 1a61ae11803..3396258c611 100644 --- a/packages/blockly/core/flyout_button.ts +++ b/packages/blockly/core/flyout_button.ts @@ -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. */ diff --git a/packages/blockly/core/hints.ts b/packages/blockly/core/hints.ts index f01d9287c79..7691897a420 100644 --- a/packages/blockly/core/hints.ts +++ b/packages/blockly/core/hints.ts @@ -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,37 @@ 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'); + 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}); +} diff --git a/packages/blockly/core/icons/icon.ts b/packages/blockly/core/icons/icon.ts index c8cfffaa4b6..30d11543c8d 100644 --- a/packages/blockly/core/icons/icon.ts +++ b/packages/blockly/core/icons/icon.ts @@ -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. * diff --git a/packages/blockly/core/interfaces/i_focusable_node.ts b/packages/blockly/core/interfaces/i_focusable_node.ts index 57ec1a126e1..37dd08bc44a 100644 --- a/packages/blockly/core/interfaces/i_focusable_node.ts +++ b/packages/blockly/core/interfaces/i_focusable_node.ts @@ -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; } /** diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts index ea2aefc36f8..60743125bbd 100644 --- a/packages/blockly/core/keyboard_nav/keyboard_mover.ts +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -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( diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index c904d04ee8b..93d14f933e1 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -53,6 +53,7 @@ export enum names { NAVIGATE_UP = 'up', NAVIGATE_DOWN = 'down', DISCONNECT = 'disconnect', + PERFORM_ACTION = 'perform_action', } /** @@ -716,6 +717,29 @@ export function registerDisconnectBlock() { ShortcutRegistry.registry.register(disconnectShortcut); } +/** + * 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. @@ -743,6 +767,7 @@ export function registerKeyboardNavigationShortcuts() { registerFocusToolbox(); registerArrowNavigation(); registerDisconnectBlock(); + registerPerformAction(); } registerDefaultShortcuts(); diff --git a/packages/blockly/core/utils/shortcut_formatting.ts b/packages/blockly/core/utils/shortcut_formatting.ts new file mode 100644 index 00000000000..8fc00d4ba83 --- /dev/null +++ b/packages/blockly/core/utils/shortcut_formatting.ts @@ -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. + * + * @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 = { + 'Control': Msg['CONTROL_KEY'], + 'Meta': Msg['COMMAND_KEY'], + 'Alt': userAgent.APPLE ? Msg['OPTION_KEY'] : Msg['ALT_KEY'], +}; + +const shortModifierNames: Record = { + '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 user facing format. + * + * 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[][] { + 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; +} diff --git a/packages/blockly/core/utils/useragent.ts b/packages/blockly/core/utils/useragent.ts index 92180c21db0..0473375b12e 100644 --- a/packages/blockly/core/utils/useragent.ts +++ b/packages/blockly/core/utils/useragent.ts @@ -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; diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index e36668ced5b..6b548647a14 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -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. diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 81444375638..8c26887794b 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-02-12 13:23:33.999357", + "lastupdated": "2026-03-18 15:02:18.076379", "locale": "en", "messagedocumentation" : "qqq" }, @@ -420,5 +420,7 @@ "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Hold %1 and use arrow keys to move freely, then %2 to accept the position", "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position", "KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.", - "KEYBOARD_NAV_CUT_HINT": "Cut. Press %1 to paste." + "KEYBOARD_NAV_CUT_HINT": "Cut. Press %1 to paste.", + "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" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 8e7be38b3e5..edd02e7fe1e 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -427,5 +427,7 @@ "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks to arbitrary locations with the keyboard.", "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks with the keyboard.", "KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.", - "KEYBOARD_NAV_CUT_HINT": "Message shown when an item is cut in keyboard navigation mode." + "KEYBOARD_NAV_CUT_HINT": "Message shown when an item is cut in keyboard navigation mode.", + "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." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 6ae66c40a44..b578784e039 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1695,4 +1695,10 @@ Blockly.Msg.KEYBOARD_NAV_CONSTRAINED_MOVE_HINT = 'Use the arrow keys to move, th Blockly.Msg.KEYBOARD_NAV_COPIED_HINT = 'Copied. Press %1 to paste.'; /** @type {string} */ /// Message shown when an item is cut in keyboard navigation mode. -Blockly.Msg.KEYBOARD_NAV_CUT_HINT = 'Cut. Press %1 to paste.'; \ No newline at end of file +Blockly.Msg.KEYBOARD_NAV_CUT_HINT = 'Cut. Press %1 to paste.'; +/** @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'; \ No newline at end of file From 8114110aaa1a2ad31aa290a9fcc9efb3db6d24a5 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 19 Mar 2026 11:29:36 -0700 Subject: [PATCH 2/4] test: Add tests --- .../tests/mocha/shortcut_items_test.js | 232 ++++++++++++++++-- 1 file changed, 214 insertions(+), 18 deletions(-) diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 44e1e42bf62..2bb24e957e1 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -19,8 +19,8 @@ import {createKeyDownEvent} from './test_helpers/user_input.js'; suite('Keyboard Shortcut Items', function () { setup(function () { sharedTestSetup.call(this); - const toolbox = document.getElementById('toolbox-categories'); - this.workspace = Blockly.inject('blocklyDiv', {toolbox}); + const toolbox = document.getElementById('toolbox-test'); + this.workspace = Blockly.inject('blocklyDiv', {toolbox, renderer: 'zelos'}); this.injectionDiv = this.workspace.getInjectionDiv(); Blockly.ContextMenuRegistry.registry.reset(); Blockly.ContextMenuItems.registerDefaultOptions(); @@ -553,22 +553,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); @@ -799,4 +783,216 @@ 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(); + }); + + test('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); + }); + }); }); From 8dbb0faacf2fb1087677f2c4c1756ba04e1d8300 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 1 Apr 2026 14:46:34 -0700 Subject: [PATCH 3/4] chore: Add example of shortcut formats --- packages/blockly/core/utils/shortcut_formatting.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/utils/shortcut_formatting.ts b/packages/blockly/core/utils/shortcut_formatting.ts index 8fc00d4ba83..1009777c0fb 100644 --- a/packages/blockly/core/utils/shortcut_formatting.ts +++ b/packages/blockly/core/utils/shortcut_formatting.ts @@ -28,7 +28,7 @@ export function getShortActionShortcut(action: string): string { /** * Find the relevant shortcuts for the given action for the current platform. - * Keys are returned in a long user facing format. + * Keys are returned in a long user facing format, e.g. "Command ⌘ Option ⌥ C" * * @internal * @param action The action name, e.g. "cut". @@ -52,7 +52,7 @@ const shortModifierNames: Record = { /** * Find the relevant shortcuts for the given action for the current platform. - * Keys are returned in a user facing format. + * 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. From 3c83dd79b7cf157fa747b090adaa56f4831f6818 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 2 Apr 2026 15:46:34 -0700 Subject: [PATCH 4/4] chore: Run formatter --- packages/blockly/tests/mocha/shortcut_items_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 84e343e5434..1bda6853feb 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -995,7 +995,7 @@ suite('Keyboard Shortcut Items', function () { this.workspace.registerButtonCallback('CREATE_VARIABLE', oldCallback); }); }); - + suite('Stack navigation (N / B)', function () { const keyNextStack = () => createKeyDownEvent(Blockly.utils.KeyCodes.N); const keyPrevStack = () => createKeyDownEvent(Blockly.utils.KeyCodes.B);