Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 70 additions & 10 deletions packages/blockly/core/shortcut_items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import {type IFocusableNode} from './interfaces/i_focusable_node.js';
import {isSelectable} from './interfaces/i_selectable.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';
Expand Down Expand Up @@ -56,6 +58,7 @@ export enum names {
DISCONNECT = 'disconnect',
NEXT_STACK = 'next_stack',
PREVIOUS_STACK = 'previous_stack',
INFORMATION = 'information',
}

/**
Expand Down Expand Up @@ -638,20 +641,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(),
Expand Down Expand Up @@ -692,6 +695,55 @@ export function registerFocusToolbox() {
ShortcutRegistry.registry.register(focusToolboxShortcut);
}

/**
* Registers keyboard shortcut to get count of block stacks and comments.
*/
export function registerWorkspaceOverview() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that when we backport the existing i shortcut (which currently works only on blocks, and only in the screenreader experiment branch), it should be merged with this shortcut, so that there's only one "i" shortcut. That will make it easier to show in the keyboard shortcut help menu as it only needs to appear once.

But doesn't need to be changed for this PR, can be changed at the time we backport it. Since that will happen before v13 is merged I'm not worried about changing the name of the exported function here.

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';
}

// 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);

return true;
},
keyCodes: [KeyCodes.I],
};
ShortcutRegistry.registry.register(shortcut);
}

/**
* Registers keyboard shortcut to disconnect the focused block.
*/
Expand Down Expand Up @@ -818,5 +870,13 @@ export function registerKeyboardNavigationShortcuts() {
registerStackNavigation();
}

/**
* Registers keyboard shortcuts used to announce screen reader information.
*/
export function registerScreenReaderShortcuts() {
registerWorkspaceOverview();
}

registerDefaultShortcuts();
registerKeyboardNavigationShortcuts();
registerScreenReaderShortcuts();
1 change: 0 additions & 1 deletion packages/blockly/core/utils/aria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions packages/blockly/msg/json/en.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2026-02-12 13:23:33.999357",
"lastupdated": "2026-04-03 10:36:19.846436",
"locale": "en",
"messagedocumentation" : "qqq"
},
Expand Down Expand Up @@ -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.",
"WORKSPACE_CONTENTS_BLOCKS_MANY": "%1 stacks of blocks%2 in workspace.",
"WORKSPACE_CONTENTS_BLOCKS_ONE": "One stack of blocks%2 in workspace.",
"WORKSPACE_CONTENTS_BLOCKS_ZERO": "No blocks%2 in workspace.",
"WORKSPACE_CONTENTS_COMMENTS_MANY": " and %1 comments",
"WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment"
}
7 changes: 6 additions & 1 deletion packages/blockly/msg/json/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
"WORKSPACE_CONTENTS_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 \n\nExamples:\n* '5 stacks of blocks in workspace.'\n* '5 stacks of blocks and 2 comments in workspace.'",
"WORKSPACE_CONTENTS_BLOCKS_ONE": "ARIA live region message announcing there is one stack of blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'One stack of blocks in workspace.'\n* 'One stack of blocks and 1 comment in workspace.'",
"WORKSPACE_CONTENTS_BLOCKS_ZERO": "ARIA live region message announcing there are no blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'No blocks in workspace.'\n* 'No blocks and 3 comments in workspace.'",
"WORKSPACE_CONTENTS_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)",
"WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment."
}
24 changes: 23 additions & 1 deletion packages/blockly/msg/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -1695,4 +1695,26 @@ 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.';
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
/// \n\nExamples:\n* "5 stacks of blocks in workspace."\n* "5 stacks of blocks and 2 comments in workspace."
Blockly.Msg.WORKSPACE_CONTENTS_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 a count of comments.
/// \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space
/// \n\nExamples:\n* "One stack of blocks in workspace."\n* "One stack of blocks and 1 comment in workspace."
Blockly.Msg.WORKSPACE_CONTENTS_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 a count of comments.
/// \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space
/// \n\nExamples:\n* "No blocks in workspace."\n* "No blocks and 3 comments in workspace."
Blockly.Msg.WORKSPACE_CONTENTS_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.WORKSPACE_CONTENTS_COMMENTS_MANY = ' and %1 comments';
/** @type {string} */
/// ARIA live region phrase appended when there is exactly one workspace comment.
Blockly.Msg.WORKSPACE_CONTENTS_COMMENTS_ONE = ' and one comment';
75 changes: 75 additions & 0 deletions packages/blockly/tests/mocha/shortcut_items_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('Empty workspace', function () {
// Start with empty workspace.
Blockly.getFocusManager().focusNode(this.workspace);
this.assertAnnouncement('No blocks in workspace.');
});

test('One block', function () {
this.workspace.newBlock('stack_block');
Blockly.getFocusManager().focusNode(this.workspace);
this.assertAnnouncement('One stack of blocks in workspace.');
});

test('Two blocks', function () {
this.workspace.newBlock('stack_block');
this.workspace.newBlock('stack_block');
Blockly.getFocusManager().focusNode(this.workspace);
this.assertAnnouncement('2 stacks of blocks in workspace.');
});

test('One comment', function () {
this.workspace.newComment();
Blockly.getFocusManager().focusNode(this.workspace);
this.assertAnnouncement('No blocks and one comment in workspace.');
});

test('Two comments', function () {
this.workspace.newComment();
this.workspace.newComment();
Blockly.getFocusManager().focusNode(this.workspace);
this.assertAnnouncement('No blocks and 2 comments in workspace.');
});

test('One block, one comment', function () {
this.workspace.newBlock('stack_block');
this.workspace.newComment();
Blockly.getFocusManager().focusNode(this.workspace);
this.assertAnnouncement(
'One stack of blocks and one comment in workspace.',
);
});

test('Two blocks, two comments', function () {
this.workspace.newBlock('stack_block');
this.workspace.newBlock('stack_block');
this.workspace.newComment();
this.workspace.newComment();
Blockly.getFocusManager().focusNode(this.workspace);
this.assertAnnouncement(
'2 stacks of blocks and 2 comments 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([
Expand Down