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
This commit is contained in:
Aaron Dodson
2026-05-14 09:41:39 -07:00
committed by GitHub
parent 53b75a99a8
commit 799965fa22
10 changed files with 227 additions and 26 deletions
+10
View File
@@ -1879,6 +1879,16 @@ export class BlockSvg
}
}
/**
* Returns the number of blocks that this block is nested inside of.
*
* @internal
*/
getNestingLevel(): number {
const surroundParent = this.getSurroundParent();
return surroundParent ? surroundParent.getNestingLevel() + 1 : 0;
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
// For full-block fields, we focus the field itself
+22
View File
@@ -18,6 +18,7 @@ const blockNavigationHintId = 'blockNavigationHint';
const workspaceNavigationHintId = 'workspaceNavigationHint';
const copiedHintId = 'copiedHint';
const cutHintId = 'cutHint';
const screenreaderHintId = 'screenreaderHint';
/**
* Nudge the user to use unconstrained movement.
@@ -153,3 +154,24 @@ export function clearPasteHints(workspace: WorkspaceSvg) {
Toast.hide(workspace, cutHintId);
Toast.hide(workspace, copiedHintId);
}
/**
* Inform the user about screenreader optimization mode being toggled, and how
* to undo it.
*
* @param workspace The workspace where screenreader mode was toggled.
* @param enabled True if screenreader mode is now enabled, otherwise false.
*/
export function showScreenreaderModeHint(
workspace: WorkspaceSvg,
enabled: boolean,
) {
Toast.show(workspace, {
message: (enabled
? Msg['SCREENREADER_MODE_ENABLED']
: Msg['SCREENREADER_MODE_DISABLED']
).replace('%1', getShortcutKeysShort(names.TOGGLE_SCREENREADER)),
duration: 7,
id: screenreaderHintId,
});
}
@@ -12,6 +12,8 @@
export class KeyboardNavigationController {
/** Whether the user is actively using keyboard navigation. */
private isActive = false;
/** Whether to play audio cues when navigating between scope levels. */
private scopeChangeAudioCuesEnabled = false;
/** Css class name added to body if keyboard nav is active. */
private activeClassName = 'blocklyKeyboardNavigation';
@@ -49,6 +51,22 @@ export class KeyboardNavigationController {
return this.isActive;
}
/**
* Sets whether or not audio cues should be played when keyboard navigation
* transitions between blocks of different nesting levels.
*/
setScopeChangeAudioCuesEnabled(enabled: boolean) {
this.scopeChangeAudioCuesEnabled = enabled;
}
/**
* Returns whether or not audio cues should be played when keyboard navigation
* transitions between blocks of different nesting levels.
*/
getScopeChangeAudioCuesEnabled() {
return this.scopeChangeAudioCuesEnabled;
}
/** Adds or removes the css class that indicates keyboard navigation is active. */
private updateActiveVisualization() {
if (this.isActive) {
+63 -7
View File
@@ -14,7 +14,12 @@ import * as contextmenu from './contextmenu.js';
import * as dropDownDiv from './dropdowndiv.js';
import * as eventUtils from './events/utils.js';
import {getFocusManager} from './focus_manager.js';
import {clearPasteHints, showCopiedHint, showCutHint} from './hints.js';
import {
clearPasteHints,
showCopiedHint,
showCutHint,
showScreenreaderModeHint,
} from './hints.js';
import {hasContextMenu} from './interfaces/i_contextmenu.js';
import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
@@ -69,6 +74,7 @@ export enum names {
DUPLICATE = 'duplicate',
CLEANUP = 'cleanup',
SHOW_TOOLTIP = 'show_tooltip',
TOGGLE_SCREENREADER = 'toggle_screenreader',
}
/**
@@ -625,7 +631,10 @@ export function registerArrowNavigation() {
const node = workspace.RTL
? getFocusManager().getFocusedTree()?.getNavigator().getOutNode()
: getFocusManager().getFocusedTree()?.getNavigator().getInNode();
if (!node) return false;
if (!node) {
workspace.getAudioManager().playErrorBeep();
return false;
}
getFocusManager().focusNode(node);
return true;
},
@@ -647,7 +656,10 @@ export function registerArrowNavigation() {
const node = workspace.RTL
? getFocusManager().getFocusedTree()?.getNavigator().getInNode()
: getFocusManager().getFocusedTree()?.getNavigator().getOutNode();
if (!node) return false;
if (!node) {
workspace.getAudioManager().playErrorBeep();
return false;
}
getFocusManager().focusNode(node);
return true;
},
@@ -663,14 +675,18 @@ export function registerArrowNavigation() {
!workspace.isDragging() &&
!dropDownDiv.isVisible() &&
!widgetDiv.isVisible(),
callback: (_workspace, e) => {
callback: (workspace, e) => {
e.preventDefault();
keyboardNavigationController.setIsActive(true);
const node = getFocusManager()
.getFocusedTree()
?.getNavigator()
.getNextNode();
if (!node) return false;
if (!node) {
workspace.getAudioManager().playErrorBeep();
return false;
}
workspace.getAudioManager().maybePlayScopeChangeAudioCue(node);
getFocusManager().focusNode(node);
return true;
},
@@ -685,14 +701,18 @@ export function registerArrowNavigation() {
!workspace.isDragging() &&
!dropDownDiv.isVisible() &&
!widgetDiv.isVisible(),
callback: (_workspace, e) => {
callback: (workspace, e) => {
e.preventDefault();
keyboardNavigationController.setIsActive(true);
const node = getFocusManager()
.getFocusedTree()
?.getNavigator()
.getPreviousNode();
if (!node) return false;
if (!node) {
workspace.getAudioManager().playErrorBeep();
return false;
}
workspace.getAudioManager().maybePlayScopeChangeAudioCue(node);
getFocusManager().focusNode(node);
return true;
},
@@ -1107,6 +1127,41 @@ export function registerShowTooltip() {
ShortcutRegistry.registry.register(showTooltip);
}
/**
* Registers keyboard shortcut to toggle on or off various behaviors that
* improve the experience for individuals using screenreaders.
*/
export function registerToggleScreenreaderMode() {
const shortcut = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [
KeyCodes.CTRL_CMD,
KeyCodes.ALT,
]);
let enabled = false;
const toggleScreenreader: KeyboardShortcut = {
name: names.TOGGLE_SCREENREADER,
preconditionFn: () => true,
callback: (workspace) => {
enabled = !enabled;
keyboardNavigationController.setScopeChangeAudioCuesEnabled(enabled);
workspace.getNavigator().setNavigationLoops(!enabled);
workspace.getToolbox()?.getNavigator().setNavigationLoops(!enabled);
workspace
.getFlyout()
?.getWorkspace()
.getNavigator()
.setNavigationLoops(!enabled);
showScreenreaderModeHint(workspace, enabled);
return true;
},
keyCodes: [shortcut],
allowCollision: true,
displayText: () => Msg['SHORTCUTS_TOGGLE_SCREENREADER_MODE'],
};
ShortcutRegistry.registry.register(toggleScreenreader);
}
/**
* Registers all default keyboard shortcut item. This should be called once per
* instance of KeyboardShortcutRegistry.
@@ -1147,6 +1202,7 @@ export function registerKeyboardNavigationShortcuts() {
export function registerScreenReaderShortcuts() {
registerReadInformation();
registerReadExtendedInformation();
registerToggleScreenreaderMode();
}
registerDefaultShortcuts();
+31 -1
View File
@@ -12,6 +12,9 @@
*/
// Former goog.module ID: Blockly.WorkspaceAudio
import {getFocusManager} from './focus_manager.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import {keyboardNavigationController} from './keyboard_navigation_controller.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/**
@@ -38,7 +41,7 @@ export class WorkspaceAudio {
/**
* @param parentWorkspace The parent of the workspace this audio object
* belongs to, or null.
* belongs to if it has one, or the workspace that owns this instance.
*/
constructor(private parentWorkspace: WorkspaceSvg) {
if (window.AudioContext) {
@@ -145,6 +148,33 @@ export class WorkspaceAudio {
return this.beep(260);
}
/**
* If enabled, plays a tone corresponding to the nesting level of the given
* node when it differs from the nesting level of the currently focused node.
* These tones are generally used for accessibility purposes to indicate a
* scope transition to users who use a screenreader. This method must be
* called before focus transitions to the given node.
*
* @internal
* @param newNode The soon-to-be-focused node.
*/
maybePlayScopeChangeAudioCue(newNode: IFocusableNode) {
if (!keyboardNavigationController.getScopeChangeAudioCuesEnabled()) return;
const navigator = this.parentWorkspace.getNavigator();
const oldBlock = navigator.getSourceBlockFromNode(
getFocusManager().getFocusedNode(),
);
const newBlock = navigator.getSourceBlockFromNode(newNode);
let level = 0;
if (
oldBlock &&
newBlock &&
oldBlock.getNestingLevel() !== (level = newBlock.getNestingLevel())
) {
this.beep(400 + level * 60);
}
}
/**
* Returns whether or not playing sounds is currently allowed.
*
+1 -3
View File
@@ -379,9 +379,7 @@ export class WorkspaceSvg
/**
* Object in charge of loading, storing, and playing audio for a workspace.
*/
this.audioManager = new WorkspaceAudio(
options.parentWorkspace as WorkspaceSvg,
);
this.audioManager = new WorkspaceAudio(options.parentWorkspace ?? this);
/** This workspace's grid object or null. */
this.grid = this.options.gridPattern
+4 -1
View File
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2026-05-14 08:05:42.601410",
"lastupdated": "2026-05-14 08:47:43.920300",
"locale": "en",
"messagedocumentation" : "qqq"
},
@@ -452,6 +452,7 @@
"SHORTCUTS_DUPLICATE": "Duplicate",
"SHORTCUTS_CLEANUP": "Clean up workspace",
"SHORTCUTS_SHOW_TOOLTIP": "Show tooltip",
"SHORTCUTS_TOGGLE_SCREENREADER_MODE": "Toggle screenreader mode",
"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.",
@@ -524,6 +525,8 @@
"ARIA_LABEL_COMMENT": "Comment",
"ARIA_LABEL_COMMENT_COLLAPSE": "Collapse Comment",
"ARIA_LABEL_COMMENT_EXPAND": "Expand Comment",
"SCREENREADER_MODE_ENABLED": "Screenreader mode is on, press %1 to turn it off",
"SCREENREADER_MODE_DISABLED": "Screenreader mode is off, press %1 to turn it on",
"CURRENT_BLOCK_ANNOUNCEMENT": "Current block: %1",
"PARENT_BLOCKS_ANNOUNCEMENT": "Parent blocks: %1",
"NO_PARENT_ANNOUNCEMENT": "Current block has no parent"
+3 -14
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}}",
@@ -460,6 +446,7 @@
"SHORTCUTS_DUPLICATE": "shortcut display text for the duplicate shortcut, which duplicates the focused block or comment.",
"SHORTCUTS_CLEANUP": "shortcut display text for the cleanup shortcut, which organizes blocks on the workspace.",
"SHORTCUTS_SHOW_TOOLTIP": "shortcut display text for the show tooltip shortcut, which displays a short help text for the focused element.",
"SHORTCUTS_TOGGLE_SCREENREADER_MODE": "shortcut display text for a shortcut that toggles various behaviors to improve the experience of individuals using screenreaders.",
"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.",
@@ -532,6 +519,8 @@
"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.",
"SCREENREADER_MODE_ENABLED": "Message announced when screenreader optimization mode is turned on.",
"SCREENREADER_MODE_DISABLED": "Message announced when screenreader optimization mode is turned off.",
"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."
+9
View File
@@ -1787,6 +1787,9 @@ Blockly.Msg.SHORTCUTS_CLEANUP = 'Clean up workspace';
/// shortcut display text for the show tooltip shortcut, which displays a short help text for the focused element.
Blockly.Msg.SHORTCUTS_SHOW_TOOLTIP = 'Show tooltip';
/** @type {string} */
/// shortcut display text for a shortcut that toggles various behaviors to improve the experience of individuals using screenreaders.
Blockly.Msg.SHORTCUTS_TOGGLE_SCREENREADER_MODE = 'Toggle screenreader mode';
/** @type {string} */
/// Message shown to inform users how to move blocks to arbitrary locations
/// with the keyboard.
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';
/// ARIA label for a collapsed comment's expand button.
Blockly.Msg.ARIA_LABEL_COMMENT_EXPAND = 'Expand Comment';
/** @type {string} */
/// Message announced when screenreader optimization mode is turned on.
Blockly.Msg.SCREENREADER_MODE_ENABLED = 'Screenreader mode is on, press %1 to turn it off';
/** @type {string} */
/// Message announced when screenreader optimization mode is turned off.
Blockly.Msg.SCREENREADER_MODE_DISABLED = 'Screenreader mode is off, press %1 to turn it on';
/** @type {string} */
/// Screenreader announcement providing context about the currently focused block.
Blockly.Msg.CURRENT_BLOCK_ANNOUNCEMENT = 'Current block: %1';
/** @type {string} */
@@ -1836,4 +1836,70 @@ suite('Keyboard Shortcut Items', function () {
assert.isFalse(Blockly.Tooltip.isVisible());
});
});
suite('Toggle screenreader mode (Ctrl+Alt+Z / Cmd+Option+Z)', function () {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.Z, [
Blockly.utils.KeyCodes.CTRL_CMD,
Blockly.utils.KeyCodes.ALT,
]);
setup(function () {
this.liveRegion = document.getElementById('blocklyAriaAnnounce');
});
test('Can be toggled', function () {
assert.isTrue(this.workspace.getNavigator().getNavigationLoops());
assert.isTrue(
this.workspace.getToolbox().getNavigator().getNavigationLoops(),
);
assert.isTrue(
this.workspace
.getFlyout()
.getWorkspace()
.getNavigator()
.getNavigationLoops(),
);
assert.isFalse(
Blockly.keyboardNavigationController.getScopeChangeAudioCuesEnabled(),
);
this.injectionDiv.dispatchEvent(event);
this.clock.runAll();
assert.isFalse(this.workspace.getNavigator().getNavigationLoops());
assert.isFalse(
this.workspace.getToolbox().getNavigator().getNavigationLoops(),
);
assert.isFalse(
this.workspace
.getFlyout()
.getWorkspace()
.getNavigator()
.getNavigationLoops(),
);
assert.isTrue(
Blockly.keyboardNavigationController.getScopeChangeAudioCuesEnabled(),
);
assert.include(this.liveRegion.textContent, 'Screenreader mode is on');
this.injectionDiv.dispatchEvent(event);
this.clock.runAll();
assert.isTrue(this.workspace.getNavigator().getNavigationLoops());
assert.isTrue(
this.workspace.getToolbox().getNavigator().getNavigationLoops(),
);
assert.isTrue(
this.workspace
.getFlyout()
.getWorkspace()
.getNavigator()
.getNavigationLoops(),
);
assert.isFalse(
Blockly.keyboardNavigationController.getScopeChangeAudioCuesEnabled(),
);
assert.include(this.liveRegion.textContent, 'Screenreader mode is off');
});
});
});