Skip to content

Commit 799965f

Browse files
authored
feat!: Add keyboard shortcut to toggle screenreader mode (#9869)
* feat!: Add keyboard shortcut to toggle screenreader mode * chore: Run formatter * chore: Fix lint * fix: Announce screenreader mode changes via toast * chore: Adjust naming
1 parent 53b75a9 commit 799965f

10 files changed

Lines changed: 227 additions & 26 deletions

File tree

packages/blockly/core/block_svg.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1879,6 +1879,16 @@ export class BlockSvg
18791879
}
18801880
}
18811881

1882+
/**
1883+
* Returns the number of blocks that this block is nested inside of.
1884+
*
1885+
* @internal
1886+
*/
1887+
getNestingLevel(): number {
1888+
const surroundParent = this.getSurroundParent();
1889+
return surroundParent ? surroundParent.getNestingLevel() + 1 : 0;
1890+
}
1891+
18821892
/** See IFocusableNode.getFocusableElement. */
18831893
getFocusableElement(): HTMLElement | SVGElement {
18841894
// For full-block fields, we focus the field itself

packages/blockly/core/hints.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const blockNavigationHintId = 'blockNavigationHint';
1818
const workspaceNavigationHintId = 'workspaceNavigationHint';
1919
const copiedHintId = 'copiedHint';
2020
const cutHintId = 'cutHint';
21+
const screenreaderHintId = 'screenreaderHint';
2122

2223
/**
2324
* Nudge the user to use unconstrained movement.
@@ -153,3 +154,24 @@ export function clearPasteHints(workspace: WorkspaceSvg) {
153154
Toast.hide(workspace, cutHintId);
154155
Toast.hide(workspace, copiedHintId);
155156
}
157+
158+
/**
159+
* Inform the user about screenreader optimization mode being toggled, and how
160+
* to undo it.
161+
*
162+
* @param workspace The workspace where screenreader mode was toggled.
163+
* @param enabled True if screenreader mode is now enabled, otherwise false.
164+
*/
165+
export function showScreenreaderModeHint(
166+
workspace: WorkspaceSvg,
167+
enabled: boolean,
168+
) {
169+
Toast.show(workspace, {
170+
message: (enabled
171+
? Msg['SCREENREADER_MODE_ENABLED']
172+
: Msg['SCREENREADER_MODE_DISABLED']
173+
).replace('%1', getShortcutKeysShort(names.TOGGLE_SCREENREADER)),
174+
duration: 7,
175+
id: screenreaderHintId,
176+
});
177+
}

packages/blockly/core/keyboard_navigation_controller.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
export class KeyboardNavigationController {
1313
/** Whether the user is actively using keyboard navigation. */
1414
private isActive = false;
15+
/** Whether to play audio cues when navigating between scope levels. */
16+
private scopeChangeAudioCuesEnabled = false;
1517
/** Css class name added to body if keyboard nav is active. */
1618
private activeClassName = 'blocklyKeyboardNavigation';
1719

@@ -49,6 +51,22 @@ export class KeyboardNavigationController {
4951
return this.isActive;
5052
}
5153

54+
/**
55+
* Sets whether or not audio cues should be played when keyboard navigation
56+
* transitions between blocks of different nesting levels.
57+
*/
58+
setScopeChangeAudioCuesEnabled(enabled: boolean) {
59+
this.scopeChangeAudioCuesEnabled = enabled;
60+
}
61+
62+
/**
63+
* Returns whether or not audio cues should be played when keyboard navigation
64+
* transitions between blocks of different nesting levels.
65+
*/
66+
getScopeChangeAudioCuesEnabled() {
67+
return this.scopeChangeAudioCuesEnabled;
68+
}
69+
5270
/** Adds or removes the css class that indicates keyboard navigation is active. */
5371
private updateActiveVisualization() {
5472
if (this.isActive) {

packages/blockly/core/shortcut_items.ts

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import * as contextmenu from './contextmenu.js';
1414
import * as dropDownDiv from './dropdowndiv.js';
1515
import * as eventUtils from './events/utils.js';
1616
import {getFocusManager} from './focus_manager.js';
17-
import {clearPasteHints, showCopiedHint, showCutHint} from './hints.js';
17+
import {
18+
clearPasteHints,
19+
showCopiedHint,
20+
showCutHint,
21+
showScreenreaderModeHint,
22+
} from './hints.js';
1823
import {hasContextMenu} from './interfaces/i_contextmenu.js';
1924
import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
2025
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
@@ -69,6 +74,7 @@ export enum names {
6974
DUPLICATE = 'duplicate',
7075
CLEANUP = 'cleanup',
7176
SHOW_TOOLTIP = 'show_tooltip',
77+
TOGGLE_SCREENREADER = 'toggle_screenreader',
7278
}
7379

7480
/**
@@ -625,7 +631,10 @@ export function registerArrowNavigation() {
625631
const node = workspace.RTL
626632
? getFocusManager().getFocusedTree()?.getNavigator().getOutNode()
627633
: getFocusManager().getFocusedTree()?.getNavigator().getInNode();
628-
if (!node) return false;
634+
if (!node) {
635+
workspace.getAudioManager().playErrorBeep();
636+
return false;
637+
}
629638
getFocusManager().focusNode(node);
630639
return true;
631640
},
@@ -647,7 +656,10 @@ export function registerArrowNavigation() {
647656
const node = workspace.RTL
648657
? getFocusManager().getFocusedTree()?.getNavigator().getInNode()
649658
: getFocusManager().getFocusedTree()?.getNavigator().getOutNode();
650-
if (!node) return false;
659+
if (!node) {
660+
workspace.getAudioManager().playErrorBeep();
661+
return false;
662+
}
651663
getFocusManager().focusNode(node);
652664
return true;
653665
},
@@ -663,14 +675,18 @@ export function registerArrowNavigation() {
663675
!workspace.isDragging() &&
664676
!dropDownDiv.isVisible() &&
665677
!widgetDiv.isVisible(),
666-
callback: (_workspace, e) => {
678+
callback: (workspace, e) => {
667679
e.preventDefault();
668680
keyboardNavigationController.setIsActive(true);
669681
const node = getFocusManager()
670682
.getFocusedTree()
671683
?.getNavigator()
672684
.getNextNode();
673-
if (!node) return false;
685+
if (!node) {
686+
workspace.getAudioManager().playErrorBeep();
687+
return false;
688+
}
689+
workspace.getAudioManager().maybePlayScopeChangeAudioCue(node);
674690
getFocusManager().focusNode(node);
675691
return true;
676692
},
@@ -685,14 +701,18 @@ export function registerArrowNavigation() {
685701
!workspace.isDragging() &&
686702
!dropDownDiv.isVisible() &&
687703
!widgetDiv.isVisible(),
688-
callback: (_workspace, e) => {
704+
callback: (workspace, e) => {
689705
e.preventDefault();
690706
keyboardNavigationController.setIsActive(true);
691707
const node = getFocusManager()
692708
.getFocusedTree()
693709
?.getNavigator()
694710
.getPreviousNode();
695-
if (!node) return false;
711+
if (!node) {
712+
workspace.getAudioManager().playErrorBeep();
713+
return false;
714+
}
715+
workspace.getAudioManager().maybePlayScopeChangeAudioCue(node);
696716
getFocusManager().focusNode(node);
697717
return true;
698718
},
@@ -1107,6 +1127,41 @@ export function registerShowTooltip() {
11071127
ShortcutRegistry.registry.register(showTooltip);
11081128
}
11091129

1130+
/**
1131+
* Registers keyboard shortcut to toggle on or off various behaviors that
1132+
* improve the experience for individuals using screenreaders.
1133+
*/
1134+
export function registerToggleScreenreaderMode() {
1135+
const shortcut = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [
1136+
KeyCodes.CTRL_CMD,
1137+
KeyCodes.ALT,
1138+
]);
1139+
1140+
let enabled = false;
1141+
1142+
const toggleScreenreader: KeyboardShortcut = {
1143+
name: names.TOGGLE_SCREENREADER,
1144+
preconditionFn: () => true,
1145+
callback: (workspace) => {
1146+
enabled = !enabled;
1147+
keyboardNavigationController.setScopeChangeAudioCuesEnabled(enabled);
1148+
workspace.getNavigator().setNavigationLoops(!enabled);
1149+
workspace.getToolbox()?.getNavigator().setNavigationLoops(!enabled);
1150+
workspace
1151+
.getFlyout()
1152+
?.getWorkspace()
1153+
.getNavigator()
1154+
.setNavigationLoops(!enabled);
1155+
showScreenreaderModeHint(workspace, enabled);
1156+
return true;
1157+
},
1158+
keyCodes: [shortcut],
1159+
allowCollision: true,
1160+
displayText: () => Msg['SHORTCUTS_TOGGLE_SCREENREADER_MODE'],
1161+
};
1162+
ShortcutRegistry.registry.register(toggleScreenreader);
1163+
}
1164+
11101165
/**
11111166
* Registers all default keyboard shortcut item. This should be called once per
11121167
* instance of KeyboardShortcutRegistry.
@@ -1147,6 +1202,7 @@ export function registerKeyboardNavigationShortcuts() {
11471202
export function registerScreenReaderShortcuts() {
11481203
registerReadInformation();
11491204
registerReadExtendedInformation();
1205+
registerToggleScreenreaderMode();
11501206
}
11511207

11521208
registerDefaultShortcuts();

packages/blockly/core/workspace_audio.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
*/
1313
// Former goog.module ID: Blockly.WorkspaceAudio
1414

15+
import {getFocusManager} from './focus_manager.js';
16+
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
17+
import {keyboardNavigationController} from './keyboard_navigation_controller.js';
1518
import type {WorkspaceSvg} from './workspace_svg.js';
1619

1720
/**
@@ -38,7 +41,7 @@ export class WorkspaceAudio {
3841

3942
/**
4043
* @param parentWorkspace The parent of the workspace this audio object
41-
* belongs to, or null.
44+
* belongs to if it has one, or the workspace that owns this instance.
4245
*/
4346
constructor(private parentWorkspace: WorkspaceSvg) {
4447
if (window.AudioContext) {
@@ -145,6 +148,33 @@ export class WorkspaceAudio {
145148
return this.beep(260);
146149
}
147150

151+
/**
152+
* If enabled, plays a tone corresponding to the nesting level of the given
153+
* node when it differs from the nesting level of the currently focused node.
154+
* These tones are generally used for accessibility purposes to indicate a
155+
* scope transition to users who use a screenreader. This method must be
156+
* called before focus transitions to the given node.
157+
*
158+
* @internal
159+
* @param newNode The soon-to-be-focused node.
160+
*/
161+
maybePlayScopeChangeAudioCue(newNode: IFocusableNode) {
162+
if (!keyboardNavigationController.getScopeChangeAudioCuesEnabled()) return;
163+
const navigator = this.parentWorkspace.getNavigator();
164+
const oldBlock = navigator.getSourceBlockFromNode(
165+
getFocusManager().getFocusedNode(),
166+
);
167+
const newBlock = navigator.getSourceBlockFromNode(newNode);
168+
let level = 0;
169+
if (
170+
oldBlock &&
171+
newBlock &&
172+
oldBlock.getNestingLevel() !== (level = newBlock.getNestingLevel())
173+
) {
174+
this.beep(400 + level * 60);
175+
}
176+
}
177+
148178
/**
149179
* Returns whether or not playing sounds is currently allowed.
150180
*

packages/blockly/core/workspace_svg.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -379,9 +379,7 @@ export class WorkspaceSvg
379379
/**
380380
* Object in charge of loading, storing, and playing audio for a workspace.
381381
*/
382-
this.audioManager = new WorkspaceAudio(
383-
options.parentWorkspace as WorkspaceSvg,
384-
);
382+
this.audioManager = new WorkspaceAudio(options.parentWorkspace ?? this);
385383

386384
/** This workspace's grid object or null. */
387385
this.grid = this.options.gridPattern

packages/blockly/msg/json/en.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"@metadata": {
33
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
4-
"lastupdated": "2026-05-14 08:05:42.601410",
4+
"lastupdated": "2026-05-14 08:47:43.920300",
55
"locale": "en",
66
"messagedocumentation" : "qqq"
77
},
@@ -452,6 +452,7 @@
452452
"SHORTCUTS_DUPLICATE": "Duplicate",
453453
"SHORTCUTS_CLEANUP": "Clean up workspace",
454454
"SHORTCUTS_SHOW_TOOLTIP": "Show tooltip",
455+
"SHORTCUTS_TOGGLE_SCREENREADER_MODE": "Toggle screenreader mode",
455456
"KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Hold %1 and use arrow keys to move freely, then %2 to accept the position.",
456457
"KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position.",
457458
"KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.",
@@ -524,6 +525,8 @@
524525
"ARIA_LABEL_COMMENT": "Comment",
525526
"ARIA_LABEL_COMMENT_COLLAPSE": "Collapse Comment",
526527
"ARIA_LABEL_COMMENT_EXPAND": "Expand Comment",
528+
"SCREENREADER_MODE_ENABLED": "Screenreader mode is on, press %1 to turn it off",
529+
"SCREENREADER_MODE_DISABLED": "Screenreader mode is off, press %1 to turn it on",
527530
"CURRENT_BLOCK_ANNOUNCEMENT": "Current block: %1",
528531
"PARENT_BLOCKS_ANNOUNCEMENT": "Parent blocks: %1",
529532
"NO_PARENT_ANNOUNCEMENT": "Current block has no parent"

packages/blockly/msg/json/qqq.json

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,4 @@
11
{
2-
"@metadata": {
3-
"authors": [
4-
"Ajeje Brazorf",
5-
"Amire80",
6-
"Espertus",
7-
"Liuxinyu970226",
8-
"McDutchie",
9-
"Metalhead64",
10-
"Nike",
11-
"Robby",
12-
"Shirayuki",
13-
"YaronSh"
14-
]
15-
},
162
"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}}",
173
"UNNAMED_KEY": "default name - A simple, default name for an unnamed function or variable. Preferably indicates that the item is unnamed.",
184
"TODAY": "button text - Button that sets a calendar to today's date.\n{{Identical|Today}}",
@@ -460,6 +446,7 @@
460446
"SHORTCUTS_DUPLICATE": "shortcut display text for the duplicate shortcut, which duplicates the focused block or comment.",
461447
"SHORTCUTS_CLEANUP": "shortcut display text for the cleanup shortcut, which organizes blocks on the workspace.",
462448
"SHORTCUTS_SHOW_TOOLTIP": "shortcut display text for the show tooltip shortcut, which displays a short help text for the focused element.",
449+
"SHORTCUTS_TOGGLE_SCREENREADER_MODE": "shortcut display text for a shortcut that toggles various behaviors to improve the experience of individuals using screenreaders.",
463450
"KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks to arbitrary locations with the keyboard.",
464451
"KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks with the keyboard.",
465452
"KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.",
@@ -532,6 +519,8 @@
532519
"ARIA_LABEL_COMMENT": "ARIA label for a comment.",
533520
"ARIA_LABEL_COMMENT_COLLAPSE": "ARIA label for an expanded comment's collapse button.",
534521
"ARIA_LABEL_COMMENT_EXPAND": "ARIA label for a collapsed comment's expand button.",
522+
"SCREENREADER_MODE_ENABLED": "Message announced when screenreader optimization mode is turned on.",
523+
"SCREENREADER_MODE_DISABLED": "Message announced when screenreader optimization mode is turned off.",
535524
"CURRENT_BLOCK_ANNOUNCEMENT": "Screenreader announcement providing context about the currently focused block.",
536525
"PARENT_BLOCKS_ANNOUNCEMENT": "Screenreader announcement providing context about the currently focused block's parents.",
537526
"NO_PARENT_ANNOUNCEMENT": "Screenreader announcement informing users that the currently focused block has no parent blocks."

packages/blockly/msg/messages.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1787,6 +1787,9 @@ Blockly.Msg.SHORTCUTS_CLEANUP = 'Clean up workspace';
17871787
/// shortcut display text for the show tooltip shortcut, which displays a short help text for the focused element.
17881788
Blockly.Msg.SHORTCUTS_SHOW_TOOLTIP = 'Show tooltip';
17891789
/** @type {string} */
1790+
/// shortcut display text for a shortcut that toggles various behaviors to improve the experience of individuals using screenreaders.
1791+
Blockly.Msg.SHORTCUTS_TOGGLE_SCREENREADER_MODE = 'Toggle screenreader mode';
1792+
/** @type {string} */
17901793
/// Message shown to inform users how to move blocks to arbitrary locations
17911794
/// with the keyboard.
17921795
Blockly.Msg.KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT = 'Hold %1 and use arrow keys to move freely, then %2 to accept the position.';
@@ -2073,6 +2076,12 @@ Blockly.Msg.ARIA_LABEL_COMMENT_COLLAPSE = 'Collapse Comment';
20732076
/// ARIA label for a collapsed comment's expand button.
20742077
Blockly.Msg.ARIA_LABEL_COMMENT_EXPAND = 'Expand Comment';
20752078
/** @type {string} */
2079+
/// Message announced when screenreader optimization mode is turned on.
2080+
Blockly.Msg.SCREENREADER_MODE_ENABLED = 'Screenreader mode is on, press %1 to turn it off';
2081+
/** @type {string} */
2082+
/// Message announced when screenreader optimization mode is turned off.
2083+
Blockly.Msg.SCREENREADER_MODE_DISABLED = 'Screenreader mode is off, press %1 to turn it on';
2084+
/** @type {string} */
20762085
/// Screenreader announcement providing context about the currently focused block.
20772086
Blockly.Msg.CURRENT_BLOCK_ANNOUNCEMENT = 'Current block: %1';
20782087
/** @type {string} */

0 commit comments

Comments
 (0)