Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/blockly/core/block_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions packages/blockly/core/hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
});
}
18 changes: 18 additions & 0 deletions packages/blockly/core/keyboard_navigation_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down
70 changes: 63 additions & 7 deletions packages/blockly/core/shortcut_items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,6 +74,7 @@ export enum names {
DUPLICATE = 'duplicate',
CLEANUP = 'cleanup',
SHOW_TOOLTIP = 'show_tooltip',
TOGGLE_SCREENREADER = 'toggle_screenreader',
}

/**
Expand Down Expand Up @@ -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;
},
Expand All @@ -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;
},
Expand All @@ -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;
},
Expand All @@ -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;
},
Expand Down Expand Up @@ -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);
Comment thread
gonfunko marked this conversation as resolved.
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.
Expand Down Expand Up @@ -1147,6 +1202,7 @@ export function registerKeyboardNavigationShortcuts() {
export function registerScreenReaderShortcuts() {
registerReadInformation();
registerReadExtendedInformation();
registerToggleScreenreaderMode();
}

registerDefaultShortcuts();
Expand Down
32 changes: 31 additions & 1 deletion packages/blockly/core/workspace_audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
*
Expand Down
4 changes: 1 addition & 3 deletions packages/blockly/core/workspace_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/blockly/msg/json/en.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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"
Expand Down
17 changes: 3 additions & 14 deletions packages/blockly/msg/json/qqq.json
Original file line number Diff line number Diff line change
@@ -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}}",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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."
Expand Down
9 changes: 9 additions & 0 deletions packages/blockly/msg/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down Expand Up @@ -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} */
Expand Down
Loading