diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 65687440cfe..0f3d385d0a7 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -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 diff --git a/packages/blockly/core/hints.ts b/packages/blockly/core/hints.ts index d6b12cca030..063c5137622 100644 --- a/packages/blockly/core/hints.ts +++ b/packages/blockly/core/hints.ts @@ -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, + }); +} diff --git a/packages/blockly/core/keyboard_navigation_controller.ts b/packages/blockly/core/keyboard_navigation_controller.ts index d0a766daff2..37fc447d8ac 100644 --- a/packages/blockly/core/keyboard_navigation_controller.ts +++ b/packages/blockly/core/keyboard_navigation_controller.ts @@ -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) { diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 87085e79e9b..bbb38016ca4 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -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(); diff --git a/packages/blockly/core/workspace_audio.ts b/packages/blockly/core/workspace_audio.ts index 66d555cd44b..6b1aa4c1464 100644 --- a/packages/blockly/core/workspace_audio.ts +++ b/packages/blockly/core/workspace_audio.ts @@ -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. * diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index 68a9db28b5a..4f227d617c0 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -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 diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 22cafc3f392..7c24c37a7db 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "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" diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 1867b58f159..1d1dca07940 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -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." diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 4a71e140ae0..103e533d91e 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -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} */ diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 239b9524fc5..67cabe650c9 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -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'); + }); + }); });