mirror of
https://github.com/google/blockly.git
synced 2026-05-22 20:10:11 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user