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