From abbc6f031ed0792e41a2bb68da4ae5848ac16fb5 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:20:40 -0400 Subject: [PATCH] fix: Do not re-announce moves that stay on the same connection target --- .../core/dragging/block_drag_strategy.ts | 26 +++++++++++ packages/blockly/tests/mocha/aria_test.js | 24 ++++++++++ .../tests/mocha/keyboard_movement_test.js | 46 +++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index 3ed6fbead18..4699fdcd303 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -92,6 +92,15 @@ export class BlockDragStrategy implements IDragStrategy { /** Used to persist an event group when snapping is done async. */ private originalEventGroup = ''; + /** + * Half of the connection of the last announced candidate, or null if that + * announcement was for the workspace. Undefined if nothing has been + * announced yet this drag. + */ + private lastAnnouncedLocal: RenderedConnection | null | undefined = undefined; + private lastAnnouncedNeighbour: RenderedConnection | null | undefined = + undefined; + /** * Map from block IDs to reason(s) why it was disabled, used to restore * disabled state post-drag. @@ -277,6 +286,21 @@ export class BlockDragStrategy implements IDragStrategy { announcementTemplate = Msg['ANNOUNCE_MOVE_WORKSPACE']; announcement = announcementTemplate.replace('%1', blockLabel); } + + // Skip only when the connection target is unchanged, so identically + // worded moves to different inputs (e.g. empty else-if slots) still get read. + const local = this.connectionCandidate?.local ?? null; + const neighbour = this.connectionCandidate?.neighbour ?? null; + if ( + this.lastAnnouncedLocal !== undefined && + local === this.lastAnnouncedLocal && + neighbour === this.lastAnnouncedNeighbour + ) { + return; + } + this.lastAnnouncedLocal = local; + this.lastAnnouncedNeighbour = neighbour; + // Collapse whitespace from unused template substitutions. aria.announceDynamicAriaState(announcement.replace(/\s+/g, ' ')); } @@ -319,6 +343,8 @@ export class BlockDragStrategy implements IDragStrategy { this.block.workspace.recordDragTargets(); this.dragging = true; + this.lastAnnouncedLocal = undefined; + this.lastAnnouncedNeighbour = undefined; this.fireDragStartEvent(); this.startLoc = this.block.getRelativeToSurfaceXY(); diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 41a1f41aa32..b4db5c6b981 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -121,6 +121,30 @@ suite('ARIA', function () { ); }); + test('repeated drags with unchanged state are not re-announced', function () { + const block = this.workspace.newBlock('basic_block'); + block.initSvg(); + block.render(); + + const startLoc = block.getRelativeToSurfaceXY(); + block.startDrag(); + this.clock.tick(11); + const initialAnnouncementText = this.liveRegion.textContent; + assert.equal(initialAnnouncementText, 'Moving default on workspace.'); + + block.drag(new Blockly.utils.Coordinate(startLoc.x + 20, startLoc.y)); + block.drag(new Blockly.utils.Coordinate(startLoc.x + 40, startLoc.y)); + block.drag(new Blockly.utils.Coordinate(startLoc.x + 60, startLoc.y)); + this.clock.tick(11); + + // Subsequent announcements during a move will drop the getStackBlocksCountLabel() + // and toggle a non-breaking space at the end. If the move candidate hasn't + // changed, the live region shouldn't be udpated. + assert.equal(this.liveRegion.textContent, initialAnnouncementText); + + block.endDrag(undefined, Blockly.DragDisposition.COMMIT); + }); + test('Uses maximal assertiveness when coalescing', function () { Blockly.utils.aria.announceDynamicAriaState('First message', { assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.OFF, diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index ca488ad03cd..2d9c39ee299 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -1599,6 +1599,52 @@ suite('Keyboard-driven movement', function () { cancelMove(this.workspace); }); + test('re-announces when moving between identically labeled inputs', function () { + const json = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'controls_if', + 'id': 'ifBlock', + 'x': 0, + 'y': 100, + 'extraState': { + 'elseIfCount': 2, + }, + }, + ], + }, + }; + Blockly.serialization.workspaces.load(json, this.workspace); + const boolean = this.workspace.newBlock('logic_boolean'); + boolean.initSvg(); + boolean.render(); + this.workspace.cleanUp(); + + Blockly.getFocusManager().focusNode(boolean); + startMove(this.workspace); + // Skip the first `if` value input. + moveRight(this.workspace); + + this.moveAndAssert( + moveRight, + ['Moving', 'to', 'else if'], + [this.getBlockLabel(boolean)], + ); + const afterFirstElseIf = this.liveRegion.textContent; + + this.moveAndAssert( + moveRight, + ['Moving', 'to', 'else if'], + [this.getBlockLabel(boolean)], + ); + + const afterSecondElseIf = this.liveRegion.textContent; + assert.notEqual(afterSecondElseIf, afterFirstElseIf); + + cancelMove(this.workspace); + }); test('disambiguates between unlabeled value inputs', function () { const textJoin = this.workspace.newBlock('text_join'); textJoin.itemCount_ = 3;