From 89737a6b4b99b3f813a097562d9f9e4ff25c9438 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:36:41 -0400 Subject: [PATCH] feat: i shortcut on workspace gives overview --- packages/blockly/core/shortcut_items.ts | 80 ++++++++++++++++--- packages/blockly/core/utils/aria.ts | 1 - packages/blockly/msg/json/en.json | 9 ++- packages/blockly/msg/json/qqq.json | 7 +- packages/blockly/msg/messages.js | 21 ++++- .../tests/mocha/shortcut_items_test.js | 75 +++++++++++++++++ 6 files changed, 178 insertions(+), 15 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index c904d04ee8b..a3ad2755350 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -19,7 +19,9 @@ import {type IDraggable, isDraggable} from './interfaces/i_draggable.js'; import {type IFocusableNode} from './interfaces/i_focusable_node.js'; import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js'; import {keyboardNavigationController} from './keyboard_navigation_controller.js'; +import {Msg} from './msg.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; +import {aria} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import {KeyCodes} from './utils/keycodes.js'; import {Rect} from './utils/rect.js'; @@ -53,6 +55,7 @@ export enum names { NAVIGATE_UP = 'up', NAVIGATE_DOWN = 'down', DISCONNECT = 'disconnect', + INFORMATION = 'information', } /** @@ -635,20 +638,20 @@ export function registerArrowNavigation() { } } +const resolveWorkspace = (workspace: WorkspaceSvg) => { + if (workspace.isFlyout) { + const target = workspace.targetWorkspace; + if (target) { + return resolveWorkspace(target); + } + } + return workspace.getRootWorkspace() ?? workspace; +}; + /** * Registers keyboard shortcut to focus the workspace. */ export function registerFocusWorkspace() { - const resolveWorkspace = (workspace: WorkspaceSvg) => { - if (workspace.isFlyout) { - const target = workspace.targetWorkspace; - if (target) { - return resolveWorkspace(target); - } - } - return workspace.getRootWorkspace() ?? workspace; - }; - const focusWorkspaceShortcut: KeyboardShortcut = { name: names.FOCUS_WORKSPACE, preconditionFn: (workspace) => !workspace.isDragging(), @@ -689,6 +692,55 @@ export function registerFocusToolbox() { ShortcutRegistry.registry.register(focusToolboxShortcut); } +/** + * Registers keyboard shortcut to get count of block stacks and comments. + */ +export function registerWorkspaceOverview() { + const shortcut: KeyboardShortcut = { + name: names.INFORMATION, + preconditionFn: (workspace, scope) => { + const focused = scope.focusedNode; + return focused === workspace; + }, + callback: (_workspace) => { + const workspace = resolveWorkspace(_workspace); + const stacks = workspace.getTopBlocks().length; + const comments = workspace.getTopComments().length; + + // Build base string with block stack count. + let baseMsgKey; + if (stacks === 0) { + baseMsgKey = 'ARIA_WORKSPACE_BLOCKS_ZERO'; + } else if (stacks === 1) { + baseMsgKey = 'ARIA_WORKSPACE_BLOCKS_ONE'; + } else { + baseMsgKey = 'ARIA_WORKSPACE_BLOCKS_MANY'; + } + + // Build comment suffix. + let suffix = ''; + if (comments > 0) { + suffix = Msg[ + comments === 1 + ? 'ARIA_WORKSPACE_COMMENTS_ONE' + : 'ARIA_WORKSPACE_COMMENTS_MANY' + ].replace('%1', String(comments)); + } + + // Build final message. + const msg = Msg[baseMsgKey] + .replace('%1', String(stacks)) + .replace('%2', suffix); + + aria.announceDynamicAriaState(msg); + + return true; + }, + keyCodes: [KeyCodes.I], + }; + ShortcutRegistry.registry.register(shortcut); +} + /** * Registers keyboard shortcut to disconnect the focused block. */ @@ -745,5 +797,13 @@ export function registerKeyboardNavigationShortcuts() { registerDisconnectBlock(); } +/** + * Registers keyboard shortcuts used to announce screen reader information. + */ +export function registerScreenReaderShortcuts() { + registerWorkspaceOverview(); +} + registerDefaultShortcuts(); registerKeyboardNavigationShortcuts(); +registerScreenReaderShortcuts(); diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index b324f8c6064..6e50ff161ff 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -188,7 +188,6 @@ export function removeRole(element: Element) { */ export function setRole(element: Element, roleName: Role | null) { if (!roleName) { - console.log('Removing role from element', element, roleName); removeRole(element); } else { element.setAttribute(ROLE_ATTRIBUTE, roleName); diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 81444375638..f8464a14e46 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-04-01 11:22:00.739918", "locale": "en", "messagedocumentation" : "qqq" }, @@ -420,5 +420,10 @@ "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.", + "ARIA_WORKSPACE_BLOCKS_MANY": "%1 stacks of blocks%2 in workspace.", + "ARIA_WORKSPACE_BLOCKS_ONE": "One stack of blocks%2 in workspace.", + "ARIA_WORKSPACE_BLOCKS_ZERO": "No blocks%2 in workspace.", + "ARIA_WORKSPACE_COMMENTS_MANY": " and %1 comments", + "ARIA_WORKSPACE_COMMENTS_ONE": " and one comment" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 8e7be38b3e5..bb18d8f5913 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -427,5 +427,10 @@ "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.", + "ARIA_WORKSPACE_BLOCKS_MANY": "ARIA live region message announcing the number of stacks of blocks in the workspace, optionally including comments. \n\nParameters:\n* %1 - the number of stacks (integer greater than 1)\n* %2 - optional phrase announcing comments, including leading space", + "ARIA_WORKSPACE_BLOCKS_ONE": "ARIA live region message announcing there is one stack of blocks in the workspace, optionally including comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space", + "ARIA_WORKSPACE_BLOCKS_ZERO": "ARIA live region message announcing there are no blocks in the workspace, optionally including comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space", + "ARIA_WORKSPACE_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)", + "ARIA_WORKSPACE_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 6ae66c40a44..5bf5ea7aeff 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1695,4 +1695,23 @@ 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} */ +/// ARIA live region message announcing the number of stacks of blocks in the workspace, optionally including comments. +/// \n\nParameters:\n* %1 - the number of stacks (integer greater than 1)\n* %2 - optional phrase announcing comments, including leading space +Blockly.Msg.ARIA_WORKSPACE_BLOCKS_MANY = '%1 stacks of blocks%2 in workspace.'; +/** @type {string} */ +/// ARIA live region message announcing there is one stack of blocks in the workspace, optionally including comments. +/// \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space +Blockly.Msg.ARIA_WORKSPACE_BLOCKS_ONE = 'One stack of blocks%2 in workspace.'; +/** @type {string} */ +/// ARIA live region message announcing there are no blocks in the workspace, optionally including comments. +/// \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space +Blockly.Msg.ARIA_WORKSPACE_BLOCKS_ZERO = 'No blocks%2 in workspace.'; +/** @type {string} */ +/// ARIA live region phrase appended when there are multiple workspace comments. +/// \n\nParameters:\n* %1 - the number of comments (integer greater than 1) +Blockly.Msg.ARIA_WORKSPACE_COMMENTS_MANY = ' and %1 comments'; +/** @type {string} */ +/// ARIA live region phrase appended when there is exactly one workspace comment. +Blockly.Msg.ARIA_WORKSPACE_COMMENTS_ONE = ' and one comment'; \ No newline at end of file diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 44e1e42bf62..230ab900d9b 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -552,6 +552,81 @@ suite('Keyboard Shortcut Items', function () { }); }); + suite('Workspace 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); + // The announcement may include an additional non-breaking space. + assert.include(this.liveRegion.textContent, expected); + }; + this.liveRegion = document.getElementById('blocklyAriaAnnounce'); + }); + + test('Announces block stacks and comments', function () { + // Start with empty workspace. + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('No blocks in workspace.'); + + // Add one block. + const block1 = this.workspace.newBlock('stack_block'); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('One stack of blocks in workspace.'); + + // Add second block. + const block2 = this.workspace.newBlock('stack_block'); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('2 stacks of blocks in workspace.'); + + // Add one comment. + const comment1 = this.workspace.newComment(); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement( + '2 stacks of blocks and one comment in workspace.', + ); + + // Add second comment. + const comment2 = this.workspace.newComment(); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement( + '2 stacks of blocks and 2 comments in workspace.', + ); + + // Remove second block stack. + block2.dispose(); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement( + 'One stack of blocks and 2 comments in workspace.', + ); + + // Remove last block. + block1.dispose(); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('No blocks and 2 comments in workspace.'); + + // Remove second comment. + comment2.dispose(); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('No blocks and one comment in workspace.'); + + // Remove last comment. + comment1.dispose(); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('No blocks in workspace.'); + }); + + 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(''); + }); + }); + }); + suite('Focus Toolbox (T)', function () { setup(function () { Blockly.defineBlocksWithJsonArray([