diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 1aa800a20d1..87085e79e9b 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -6,6 +6,7 @@ // Former goog.module ID: Blockly.ShortcutItems +import {computeAriaLabel} from './block_aria_composer.js'; import {BlockSvg} from './block_svg.js'; import * as clipboard from './clipboard.js'; import {RenderedWorkspaceComment} from './comments.js'; @@ -63,6 +64,7 @@ export enum names { NEXT_STACK = 'next_stack', PREVIOUS_STACK = 'previous_stack', INFORMATION = 'information', + EXTENDED_INFORMATION = 'extended_information', PERFORM_ACTION = 'perform_action', DUPLICATE = 'duplicate', CLEANUP = 'cleanup', @@ -762,51 +764,136 @@ export function registerFocusToolbox() { } /** - * Registers keyboard shortcut to get count of block stacks and comments. + * Registers keyboard shortcut to announce information about the focused + * element. */ -export function registerWorkspaceOverview() { +export function registerReadInformation() { + const announceBlockInformation = (block: BlockSvg) => { + const description = computeAriaLabel( + block, + aria.Verbosity.LOQUACIOUS, + false, + ); + aria.announceDynamicAriaState(description); + }; + + const announceWorkspaceInformation = (workspace: WorkspaceSvg) => { + const rootWorkspace = resolveWorkspace(workspace); + const stackCount = rootWorkspace.getTopBlocks().length; + const commentCount = rootWorkspace.getTopComments().length; + + // Build base string with block stack count. + let baseMsgKey; + if (stackCount === 0) { + baseMsgKey = 'WORKSPACE_CONTENTS_BLOCKS_ZERO'; + } else if (stackCount === 1) { + baseMsgKey = 'WORKSPACE_CONTENTS_BLOCKS_ONE'; + } else { + baseMsgKey = 'WORKSPACE_CONTENTS_BLOCKS_MANY'; + } + + // Build comment suffix. + let suffix = ''; + if (commentCount > 0) { + suffix = Msg[ + commentCount === 1 + ? 'WORKSPACE_CONTENTS_COMMENTS_ONE' + : 'WORKSPACE_CONTENTS_COMMENTS_MANY' + ].replace('%1', String(commentCount)); + } + + // Build final message. + const msg = Msg[baseMsgKey] + .replace('%1', String(stackCount)) + .replace('%2', suffix); + + aria.announceDynamicAriaState(msg); + }; + const shortcut: KeyboardShortcut = { name: names.INFORMATION, - preconditionFn: (workspace, scope) => { - const focused = scope.focusedNode; - return focused === workspace; - }, - callback: (_workspace) => { - const workspace = resolveWorkspace(_workspace); - const stackCount = workspace.getTopBlocks().length; - const commentCount = workspace.getTopComments().length; - - // Build base string with block stack count. - let baseMsgKey; - if (stackCount === 0) { - baseMsgKey = 'WORKSPACE_CONTENTS_BLOCKS_ZERO'; - } else if (stackCount === 1) { - baseMsgKey = 'WORKSPACE_CONTENTS_BLOCKS_ONE'; - } else { - baseMsgKey = 'WORKSPACE_CONTENTS_BLOCKS_MANY'; + preconditionFn: () => true, + callback: (workspace) => { + const focusedNode = getFocusManager().getFocusedNode(); + const block = workspace + .getNavigator() + .getSourceBlockFromNode(focusedNode); + if (block) { + announceBlockInformation(block); + return true; + } else if (focusedNode === workspace) { + announceWorkspaceInformation(workspace); + return true; } + return false; + }, + keyCodes: [KeyCodes.I], + displayText: () => Msg['SHORTCUTS_INFORMATION'], + }; + ShortcutRegistry.registry.register(shortcut); +} - // Build comment suffix. - let suffix = ''; - if (commentCount > 0) { - suffix = Msg[ - commentCount === 1 - ? 'WORKSPACE_CONTENTS_COMMENTS_ONE' - : 'WORKSPACE_CONTENTS_COMMENTS_MANY' - ].replace('%1', String(commentCount)); +/** + * Registers keyboard shortcut to announce an extended description of the + * focused element. + */ +export function registerReadExtendedInformation() { + const shiftI = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, [ + KeyCodes.SHIFT, + ]); + const shortcut: KeyboardShortcut = { + name: names.EXTENDED_INFORMATION, + preconditionFn: () => true, + callback: (workspace) => { + const block = workspace + .getNavigator() + .getSourceBlockFromNode(getFocusManager().getFocusedNode()); + if (!block) return false; + + const toAnnounce = []; + // First go up the chain of output connections and start finding parents + // from there because the outputs of a block are read anyway, so we don't + // need to repeat them. + let startBlock = block; + while (startBlock.outputConnection?.isConnected()) { + startBlock = startBlock.getParent()!; } - // Build final message. - const msg = Msg[baseMsgKey] - .replace('%1', String(stackCount)) - .replace('%2', suffix); + if (startBlock !== block) { + toAnnounce.push( + computeAriaLabel(startBlock, aria.Verbosity.TERSE, false), + ); + } - aria.announceDynamicAriaState(msg); + let parent = startBlock.getParent(); + while (parent) { + toAnnounce.push(computeAriaLabel(parent, aria.Verbosity.TERSE, false)); + parent = parent.getParent(); + } + if (toAnnounce.length) { + toAnnounce.reverse(); + if (!block.outputConnection?.isConnected()) { + // The current block was already read out earlier if it has an output + // connection. + toAnnounce.push( + Msg['CURRENT_BLOCK_ANNOUNCEMENT'].replace( + '%1', + computeAriaLabel(block, aria.Verbosity.TERSE, false), + ), + ); + } + + aria.announceDynamicAriaState( + Msg['PARENT_BLOCKS_ANNOUNCEMENT'].replace('%1', toAnnounce.join(',')), + ); + } else { + aria.announceDynamicAriaState(Msg['NO_PARENT_ANNOUNCEMENT']); + } return true; }, - keyCodes: [KeyCodes.I], - displayText: () => Msg['SHORTCUTS_INFORMATION'], + keyCodes: [shiftI], + displayText: () => Msg['SHORTCUTS_EXTENDED_INFORMATION'], }; ShortcutRegistry.registry.register(shortcut); } @@ -1058,7 +1145,8 @@ export function registerKeyboardNavigationShortcuts() { * Registers keyboard shortcuts used to announce screen reader information. */ export function registerScreenReaderShortcuts() { - registerWorkspaceOverview(); + registerReadInformation(); + registerReadExtendedInformation(); } registerDefaultShortcuts(); diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 4f2135a9761..a643fd8f9b7 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-05-11 14:15:58.197621", + "lastupdated": "2026-05-13 09:26:53.422108", "locale": "en", "messagedocumentation" : "qqq" }, @@ -444,6 +444,7 @@ "SHORTCUTS_FOCUS_WORKSPACE": "Focus workspace", "SHORTCUTS_FOCUS_TOOLBOX": "Focus toolbox", "SHORTCUTS_INFORMATION": "Announce information", + "SHORTCUTS_EXTENDED_INFORMATION": "Announce detailed information", "SHORTCUTS_DISCONNECT": "Disconnect block", "SHORTCUTS_NEXT_STACK": "Next stack", "SHORTCUTS_PREVIOUS_STACK": "Previous stack", @@ -517,5 +518,8 @@ "ICON_LABEL_WARNING_OPEN": "Close Warning", "ARIA_LABEL_COMMENT": "Comment", "ARIA_LABEL_COMMENT_COLLAPSE": "Collapse Comment", - "ARIA_LABEL_COMMENT_EXPAND": "Expand Comment" + "ARIA_LABEL_COMMENT_EXPAND": "Expand Comment", + "CURRENT_BLOCK_ANNOUNCEMENT": "Current block: %1", + "PARENT_BLOCKS_ANNOUNCEMENT": "Parent blocks: %1", + "NO_PARENT_ANNOUNCEMENT": "Current block has no parent" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 49e642f935f..bdb9ce1952b 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -1,18 +1,4 @@ { - "@metadata": { - "authors": [ - "Ajeje Brazorf", - "Amire80", - "Espertus", - "Liuxinyu970226", - "McDutchie", - "Metalhead64", - "Nike", - "Robby", - "Shirayuki", - "YaronSh" - ] - }, "VARIABLES_DEFAULT_NAME": "default name - A simple, general default name for a variable, preferably short. For more context, see [[Translating:Blockly#infrequent_message_types]].\n{{Identical|Item}}", "UNNAMED_KEY": "default name - A simple, default name for an unnamed function or variable. Preferably indicates that the item is unnamed.", "TODAY": "button text - Button that sets a calendar to today's date.\n{{Identical|Today}}", @@ -452,6 +438,7 @@ "SHORTCUTS_FOCUS_WORKSPACE": "shortcut display text for the focus workspace shortcut, which moves focus to the workspace.", "SHORTCUTS_FOCUS_TOOLBOX": "shortcut display text for the focus toolbox shortcut, which moves focus to the toolbox or flyout.", "SHORTCUTS_INFORMATION": "shortcut display text for the information shortcut, which announces information about a focused element.", + "SHORTCUTS_EXTENDED_INFORMATION": "Description for the Shift-I keyboard shortcut that announces extended context about the currently focused element to screenreaders.", "SHORTCUTS_DISCONNECT": "shortcut display text for the disconnect shortcut, which disconnects a block from its neighbor.", "SHORTCUTS_NEXT_STACK": "shortcut display text for the next stack shortcut, which navigates to the next block stack.", "SHORTCUTS_PREVIOUS_STACK": "shortcut display text for the previous stack shortcut, which navigates to the previous block stack.", @@ -525,5 +512,8 @@ "ICON_LABEL_WARNING_OPEN": "Label for an icon, used by screen readers to identify an open warning. Clicking on the icon closes the warning's bubble.", "ARIA_LABEL_COMMENT": "ARIA label for a comment.", "ARIA_LABEL_COMMENT_COLLAPSE": "ARIA label for an expanded comment's collapse button.", - "ARIA_LABEL_COMMENT_EXPAND": "ARIA label for a collapsed comment's expand button." + "ARIA_LABEL_COMMENT_EXPAND": "ARIA label for a collapsed comment's expand button.", + "CURRENT_BLOCK_ANNOUNCEMENT": "Screenreader announcement providing context about the currently focused block.", + "PARENT_BLOCKS_ANNOUNCEMENT": "Screenreader announcement providing context about the currently focused block's parents.", + "NO_PARENT_ANNOUNCEMENT": "Screenreader announcement informing users that the currently focused block has no parent blocks." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 71c3aff5ce8..943750ff4ee 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1763,6 +1763,9 @@ Blockly.Msg.SHORTCUTS_FOCUS_TOOLBOX = 'Focus toolbox'; /// shortcut display text for the information shortcut, which announces information about a focused element. Blockly.Msg.SHORTCUTS_INFORMATION = 'Announce information'; /** @type {string} */ +/// Description for the Shift-I keyboard shortcut that announces extended context about the currently focused element to screenreaders. +Blockly.Msg.SHORTCUTS_EXTENDED_INFORMATION = 'Announce detailed information'; +/** @type {string} */ /// shortcut display text for the disconnect shortcut, which disconnects a block from its neighbor. Blockly.Msg.SHORTCUTS_DISCONNECT = 'Disconnect block'; /** @type {string} */ @@ -2046,4 +2049,13 @@ Blockly.Msg.ARIA_LABEL_COMMENT = 'Comment'; Blockly.Msg.ARIA_LABEL_COMMENT_COLLAPSE = 'Collapse Comment'; /** @type {string} */ /// ARIA label for a collapsed comment's expand button. -Blockly.Msg.ARIA_LABEL_COMMENT_EXPAND = 'Expand Comment'; \ No newline at end of file +Blockly.Msg.ARIA_LABEL_COMMENT_EXPAND = 'Expand Comment'; +/** @type {string} */ +/// Screenreader announcement providing context about the currently focused block. +Blockly.Msg.CURRENT_BLOCK_ANNOUNCEMENT = 'Current block: %1'; +/** @type {string} */ +/// Screenreader announcement providing context about the currently focused block's parents. +Blockly.Msg.PARENT_BLOCKS_ANNOUNCEMENT = 'Parent blocks: %1'; +/** @type {string} */ +/// Screenreader announcement informing users that the currently focused block has no parent blocks. +Blockly.Msg.NO_PARENT_ANNOUNCEMENT = 'Current block has no parent'; diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 581e38b2d30..239b9524fc5 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -772,14 +772,14 @@ suite('Keyboard Shortcut Items', function () { }); }); - suite('Workspace Information (I)', function () { + suite('Information (I)', function () { setup(function () { const keyEvent = createKeyDownEvent(Blockly.utils.KeyCodes.I); // Helper to trigger the shortcut and assert the live region text. this.assertAnnouncement = (expected) => { this.injectionDiv.dispatchEvent(keyEvent); // Wait for the live region to update after the event. - this.clock.tick(11); + this.clock.runAll(); // The announcement may include an additional non-breaking space. assert.include(this.liveRegion.textContent, expected); }; @@ -838,12 +838,108 @@ suite('Keyboard Shortcut Items', function () { ); }); - suite('Preconditions', function () { - test('Not called when focus is not on workspace', function () { - this.block = this.workspace.newBlock('stack_block'); - Blockly.getFocusManager().focusNode(this.block); - this.assertAnnouncement(''); - }); + test('Block', function () { + const block = this.workspace.newBlock('controls_if'); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block); + this.assertAnnouncement('Begin stack, if, do, has input'); + }); + + test('Icon', function () { + const block = this.workspace.newBlock('controls_if'); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode( + block.getIcon(Blockly.icons.IconType.MUTATOR), + ); + this.assertAnnouncement('Begin stack, if, do, has input'); + }); + + test('Field', function () { + const block = this.workspace.newBlock('logic_boolean'); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block.getField('BOOL')); + this.assertAnnouncement('Begin stack, dropdown: true'); + }); + + test('Connection', function () { + const block = this.workspace.newBlock('controls_if'); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block.getInput('DO0').connection); + this.assertAnnouncement('Begin stack, if, do, has input'); + }); + }); + + suite('Extended Information (Shift + I)', function () { + setup(function () { + const keyEvent = createKeyDownEvent(Blockly.utils.KeyCodes.I, [ + Blockly.utils.KeyCodes.SHIFT, + ]); + // Helper to trigger the shortcut and assert the live region text. + this.assertAnnouncement = (expected) => { + this.injectionDiv.dispatchEvent(keyEvent); + // Wait for the live region to update after the event. + this.clock.runAll(); + // The announcement may include an additional non-breaking space. + console.log(this.liveRegion.textContent); + assert.include(this.liveRegion.textContent, expected); + }; + this.liveRegion = document.getElementById('blocklyAriaAnnounce'); + }); + + test('Top level statement block', function () { + const block = this.workspace.newBlock('controls_if'); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block); + this.assertAnnouncement('Current block has no parent'); + }); + + test('Top level value block', function () { + const block = this.workspace.newBlock('logic_negate'); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block); + this.assertAnnouncement('Current block has no parent'); + }); + + test('Nested statement block', function () { + const ifBlock = this.workspace.newBlock('controls_if'); + const repeatBlock = this.workspace.newBlock('controls_repeat_ext'); + const printBlock = this.workspace.newBlock('text_print'); + for (const block of [ifBlock, repeatBlock, printBlock]) { + block.initSvg(); + block.render(); + } + printBlock.previousConnection.connect( + repeatBlock.getInput('DO').connection, + ); + repeatBlock.previousConnection.connect( + ifBlock.getInput('DO0').connection, + ); + + Blockly.getFocusManager().focusNode(printBlock); + this.assertAnnouncement( + 'Parent blocks: if, do,repeat, times, do,Current block: print', + ); + }); + + test('Nested value block', function () { + const andBlock = this.workspace.newBlock('logic_operation'); + const notBlock = this.workspace.newBlock('logic_negate'); + const trueBlock = this.workspace.newBlock('logic_boolean'); + for (const block of [andBlock, notBlock, trueBlock]) { + block.initSvg(); + block.render(); + } + notBlock.outputConnection.connect(andBlock.getInput('B').connection); + trueBlock.outputConnection.connect(notBlock.getInput('BOOL').connection); + + Blockly.getFocusManager().focusNode(trueBlock); + this.assertAnnouncement('Parent blocks: and, not, true'); }); });