Skip to content
Open
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 @@ -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';
Expand Down Expand Up @@ -53,6 +55,7 @@ export enum names {
NAVIGATE_UP = 'up',
NAVIGATE_DOWN = 'down',
DISCONNECT = 'disconnect',
INFORMATION = 'information',
}

/**
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Comment on lines +707 to +708
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: Perhaps use stackCount and commentCount for a bit more clarity on what's being contained in this variables (since they read a bit as arrays at the moment).


// 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.
*/
Expand Down Expand Up @@ -745,5 +797,13 @@ export function registerKeyboardNavigationShortcuts() {
registerDisconnectBlock();
}

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

registerDefaultShortcuts();
registerKeyboardNavigationShortcuts();
registerScreenReaderShortcuts();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we actually want to keep this out, for now. The idea is we'll use an optional plugin to enable these based on user prompting.

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-01 11:22:00.739918",
"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.",
"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"
Comment on lines +424 to +428
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm finding this a bit hard to grok, and I suspect a translator might as well. Perhaps we could be slightly repetitive to simplify things. What if we avoided the double concatenation and had four different variables for the label:

  • "%1 stacks of blocks and %2 comments in workspace."
  • "One stack of blocks and %2 comments in workspace."
  • "%1 stacks of blocks and one comment in workspace."
  • "One stack of blocks and one comment in workspace."

Separately, I'm not sure about the Msg name here. These aren't actual ARIA labels or properties, they're just strings that happen to be used as a status message to trick the screen reader into announcing them I do recognize that we will want to standardize on this, though, so perhaps @maribethb might have a suggestion. One thought might be to do something like: WORKSPACE_MANY_BLOCKS_AND_MANY_COMMENTS_SCREEN_READER_ANNOUNCEMENT_LABEL but I'm not sure sure just how specific we want to get with these.

}
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.",
"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."
}
21 changes: 20 additions & 1 deletion packages/blockly/msg/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
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';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: please re-add EOF line terminator.

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('Announces block stacks and comments', function () {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I suggest splitting these up a bit. The Blockly team doesn't exactly follow the arrange-act-assert structure strictly in the codebase but it's a good baseline to follow particularly for longer tests like this as more granular failures can be more informative when investigating.

// 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([
Expand Down
Loading