feat!: Improve context announcement keyboard shortcuts (#9863)

* fix: Improve context announcement keyboard shortcuts

* test: Add tests

* fix: Don't use custom input labels

* fix: Use messages

* chore: Run formatter

* refactor: Improve organization of information announcement shortcut
This commit is contained in:
Aaron Dodson
2026-05-13 11:33:18 -07:00
committed by GitHub
parent e32ef656bd
commit 057356fe10
5 changed files with 255 additions and 65 deletions
+127 -39
View File
@@ -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,48 +764,68 @@ 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;
}
// 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;
return false;
},
keyCodes: [KeyCodes.I],
displayText: () => Msg['SHORTCUTS_INFORMATION'],
@@ -811,6 +833,71 @@ export function registerWorkspaceOverview() {
ShortcutRegistry.registry.register(shortcut);
}
/**
* 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()!;
}
if (startBlock !== block) {
toAnnounce.push(
computeAriaLabel(startBlock, aria.Verbosity.TERSE, false),
);
}
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: [shiftI],
displayText: () => Msg['SHORTCUTS_EXTENDED_INFORMATION'],
};
ShortcutRegistry.registry.register(shortcut);
}
/**
* Registers keyboard shortcut to disconnect the focused block.
*/
@@ -1058,7 +1145,8 @@ export function registerKeyboardNavigationShortcuts() {
* Registers keyboard shortcuts used to announce screen reader information.
*/
export function registerScreenReaderShortcuts() {
registerWorkspaceOverview();
registerReadInformation();
registerReadExtendedInformation();
}
registerDefaultShortcuts();
+6 -2
View File
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"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"
}
+5 -15
View File
@@ -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."
}
+13 -1
View File
@@ -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';
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';
@@ -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');
});
});