From a7fbc20172f0cbbfba2fb12d1d7b2582b99c006c Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:33:01 -0400 Subject: [PATCH] fix: forward shadow block context menu to parent --- packages/blockly/core/block_svg.ts | 12 +++ .../blockly/tests/mocha/contextmenu_test.js | 88 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index fba41bb22a8..37eeb3b7cf0 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -709,6 +709,18 @@ export class BlockSvg * @internal */ showContextMenu(e: Event) { + // Forward to the nearest non-shadow ancestor and focus it for keyboard users. + if (this.isShadow()) { + let parent = this.getParent(); + while (parent && parent.isShadow()) { + parent = parent.getParent(); + } + if (parent) { + getFocusManager().focusNode(parent); + parent.showContextMenu(e); + } + return; + } const menuOptions = this.generateContextMenu(e); const location = this.calculateContextMenuLocation(e); diff --git a/packages/blockly/tests/mocha/contextmenu_test.js b/packages/blockly/tests/mocha/contextmenu_test.js index 6f7aeeeab65..8cd71172766 100644 --- a/packages/blockly/tests/mocha/contextmenu_test.js +++ b/packages/blockly/tests/mocha/contextmenu_test.js @@ -7,6 +7,10 @@ import {callbackFactory} from '../../build/src/core/contextmenu.js'; import * as xmlUtils from '../../build/src/core/utils/xml.js'; import {assert} from '../../node_modules/chai/index.js'; +import { + defineRowBlock, + defineStackBlock, +} from './test_helpers/block_definitions.js'; import { sharedTestSetup, sharedTestTeardown, @@ -91,4 +95,88 @@ suite('Context Menu', function () { assert.isNull(Blockly.ContextMenu.getMenu()); }); }); + + suite('Block showContextMenu', function () { + setup(function () { + defineRowBlock(); + defineStackBlock(); + Blockly.ContextMenuRegistry.registry.reset(); + Blockly.ContextMenuItems.registerDefaultOptions(); + this.pointerEvent = new PointerEvent('pointerdown'); + }); + + teardown(function () { + if (Blockly.ContextMenu.getMenu()) { + Blockly.ContextMenu.hide(); + } + }); + + /** + * Initializes and renders the given blocks. + * @param {...Blockly.BlockSvg} blocks The blocks to initialize. + */ + function initAndRender(...blocks) { + for (const block of blocks) { + block.initSvg(); + block.render(); + } + } + + /** + * Asserts that the given block's context menu is shown and it has focus. + * @param {!Blockly.BlockSvg} block The block that should own the menu. + */ + function assertBlockContextMenu(block) { + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.instanceOf( + Blockly.ContextMenu.getMenu(), + Blockly.Menu, + 'Context menu should be shown', + ); + assert.strictEqual(Blockly.ContextMenu.getCurrentBlock(), block); + } + + test('value shadow forwards context menu to parent and focuses parent', function () { + const parent = this.workspace.newBlock('row_block'); + parent.getInput('INPUT').connection.setShadowState({type: 'row_block'}); + const shadow = parent.getInput('INPUT').connection.targetBlock(); + assert.isTrue(shadow.isShadow()); + initAndRender(parent, shadow); + + Blockly.getFocusManager().focusNode(shadow); + shadow.showContextMenu(this.pointerEvent); + + assertBlockContextMenu(parent); + }); + + test('nested shadows forward to the first non-shadow ancestor', function () { + const parent = this.workspace.newBlock('row_block'); + parent.getInput('INPUT').connection.setShadowState({type: 'row_block'}); + const middleShadow = parent.getInput('INPUT').connection.targetBlock(); + middleShadow + .getInput('INPUT') + .connection.setShadowState({type: 'row_block'}); + const childShadow = middleShadow + .getInput('INPUT') + .connection.targetBlock(); + assert.isTrue(middleShadow.isShadow()); + assert.isTrue(childShadow.isShadow()); + initAndRender(parent, middleShadow, childShadow); + + Blockly.getFocusManager().focusNode(childShadow); + childShadow.showContextMenu(this.pointerEvent); + + assertBlockContextMenu(parent); + }); + + test('non-shadow block shows its own context menu', function () { + const block = this.workspace.newBlock('stack_block'); + initAndRender(block); + Blockly.getFocusManager().focusNode(block); + + block.showContextMenu(this.pointerEvent); + + assertBlockContextMenu(block); + }); + }); });