feat: i shortcut on workspace gives overview (#9677)

* feat: i shortcut on workspace gives overview

* fix: code review changes
This commit is contained in:
Michael Harvey
2026-04-06 16:54:11 -04:00
committed by GitHub
parent 34c265fcf8
commit b49fe1ec7d
6 changed files with 181 additions and 15 deletions
+70 -10
View File
@@ -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';
@@ -56,6 +58,7 @@ export enum names {
DISCONNECT = 'disconnect',
NEXT_STACK = 'next_stack',
PREVIOUS_STACK = 'previous_stack',
INFORMATION = 'information',
}
/**
@@ -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(),
@@ -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() {
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.
*/
@@ -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
View File
@@ -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);
+7 -2
View File
@@ -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"
},
@@ -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"
}
+6 -1
View File
@@ -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."
}
+23 -1
View File
@@ -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';
@@ -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([