diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index 8cdacb21d4b..7984ff6868b 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -527,6 +527,7 @@ input[type=number] { .blocklyActiveFocus:is( .blocklyFlyout, .blocklyWorkspace, + .blocklyWorkspaceSelectionRing, .blocklyField, .blocklyPath, .blocklyHighlightedConnectionPath, @@ -650,14 +651,25 @@ input[type=number] { stroke-width: calc(var(--blockly-selection-width) * 2); } -/* The workspace itself is the active node. */ +/* The region itself is the active node (e.g. focused by clicking the + background). */ .blocklyKeyboardNavigation .blocklyWorkspace.blocklyActiveFocus - .blocklyWorkspaceSelectionRing { + .blocklyWorkspaceSelectionRing, +/* The selection ring itself is the active node (it doubles as the workspace's + keyboard focus target). */ +.blocklyKeyboardNavigation + .blocklyWorkspaceSelectionRing.blocklyActiveFocus { stroke: var(--blockly-active-node-color); stroke-width: var(--blockly-selection-width); } +/* The selection ring is a decorative highlight that can also be the workspace's + focus target; either way it should never intercept pointer events. */ +.blocklyWorkspaceSelectionRing { + pointer-events: none; +} + /* The workspace itself is the active node. */ .blocklyKeyboardNavigation .blocklyBubble.blocklyActiveFocus diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 8bff9f28388..8c7609317ff 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -782,7 +782,12 @@ export function registerFocusWorkspace() { preconditionFn: (workspace) => !workspace.isDragging(), callback: (workspace) => { keyboardNavigationController.setIsActive(true); - getFocusManager().focusNode(resolveWorkspace(workspace)); + // Focus the focus target (which announces the stack count) rather than the + // workspace region, falling back to the region if there's no focus target. + const rootWorkspace = resolveWorkspace(workspace); + getFocusManager().focusNode( + rootWorkspace.getWorkspaceFocusTarget() ?? rootWorkspace, + ); return true; }, keyCodes: [KeyCodes.W], diff --git a/packages/blockly/core/workspace_focus_target.ts b/packages/blockly/core/workspace_focus_target.ts new file mode 100644 index 00000000000..dda96dee946 --- /dev/null +++ b/packages/blockly/core/workspace_focus_target.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +/** + * A focusable element representing the workspace as a whole. + * + * Focus on the workspace itself (i.e. not on any block, comment, or other + * content) lands here rather than on the workspace's region element. VoiceOver + * does not announce a region when focus moves into it from a node already + * inside that region, so focusing the region directly (e.g. after pressing "W" + * from a block) would be silent. This is an ordinary focusable node, so moving + * to it is announced. It carries the stack count and a "workspace" role + * description; the enclosing region keeps a short, stable label + * ("Blocks workspace."). + */ +export class WorkspaceFocusTarget implements IFocusableNode { + /** + * @param workspace The workspace this focus target represents. + * @param element The SVG element that receives focus for the workspace. + */ + constructor( + private readonly workspace: WorkspaceSvg, + private readonly element: SVGElement, + ) {} + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): SVGElement { + return this.element; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.workspace; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + this.workspace.handleWorkspaceFocusTargetFocus(); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } + + /** + * Returns the workspace this focus target represents. + * + * @returns The workspace this focus target represents. + */ + getWorkspace(): WorkspaceSvg { + return this.workspace; + } +} diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index dcc7238f630..cbcba95f110 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -88,6 +88,7 @@ import * as VariablesDynamic from './variables_dynamic.js'; import * as WidgetDiv from './widgetdiv.js'; import {Workspace} from './workspace.js'; import {WorkspaceAudio} from './workspace_audio.js'; +import {WorkspaceFocusTarget} from './workspace_focus_target.js'; import {ZoomControls} from './zoom_controls.js'; /** Margin around the top/bottom/left/right after a zoomToFit call. */ @@ -339,6 +340,20 @@ export class WorkspaceSvg */ private workspaceSelectionRing: Element | null = null; + /** + * Element that receives focus when the workspace itself (rather than any of + * its contents) is focused. See {@link WorkspaceFocusTarget}. This is the + * selection ring (which already represents the workspace being the active + * node); it's set only for main (non-flyout, non-mutator) workspaces. + */ + private workspaceFocusTargetElement: SVGElement | null = null; + + /** + * Focusable node wrapping {@link workspaceFocusTargetElement}, or null if this + * workspace has no focus target. + */ + private workspaceFocusTarget: WorkspaceFocusTarget | null = null; + /** * Navigator that handles moving focus between items in this workspace in * response to keyboard navigation commands. @@ -729,26 +744,44 @@ export class WorkspaceSvg Msg['WORKSPACE_LABEL_MUTATOR_WORKSPACE'], ); } else { - // Main workspaces get labelled with how many stacks of blocks they contain - // This will be updated on focus, but set it here in case there are blocks in the initial state of the workspace - this.updateAriaLabel(); + // The region carries a short, stable label as enclosing context; the + // stack count lives on the focus target, which (unlike the region) VoiceOver + // announces when focus moves to it. See WorkspaceFocusTarget. + aria.setState( + this.svgGroup_, + aria.State.LABEL, + Msg['WORKSPACE_LABEL_PLAIN'], + ); + if (this.workspaceFocusTargetElement) { + aria.setRole(this.workspaceFocusTargetElement, aria.Role.FIGURE); + aria.setState( + this.workspaceFocusTargetElement, + aria.State.ROLEDESCRIPTION, + Msg['WORKSPACE_ROLEDESCRIPTION'], + ); + // Set here in case there are blocks in the initial state of the + // workspace; refreshed whenever the workspace regains focus. + this.updateAriaLabel(); + } } } /** - * Updates the label on the workspace to reflect the number of top-level stacks in the workspace. + * Updates the focus target's label to reflect the number of top-level stacks in the + * workspace. No-op for workspaces without a focus target (flyouts and mutators). */ private updateAriaLabel() { + if (!this.workspaceFocusTargetElement) return; const numStacks = this.getTopBlocks(false).length; if (numStacks == 1) { aria.setState( - this.svgGroup_, + this.workspaceFocusTargetElement, aria.State.LABEL, Msg['WORKSPACE_LABEL_1_STACK'], ); } else { aria.setState( - this.svgGroup_, + this.workspaceFocusTargetElement, aria.State.LABEL, Msg['WORKSPACE_LABEL_MANY_STACKS'].replace('%1', String(numStacks)), ); @@ -801,7 +834,7 @@ export class WorkspaceSvg } } - this.workspaceSelectionRing = dom.createSvgElement( + const selectionRing = dom.createSvgElement( Svg.RECT, { fill: 'none', @@ -809,6 +842,7 @@ export class WorkspaceSvg }, this.svgGroup_, ); + this.workspaceSelectionRing = selectionRing; this.workspaceFocusRing = dom.createSvgElement( Svg.RECT, { @@ -818,6 +852,18 @@ export class WorkspaceSvg this.svgGroup_, ); + // The selection ring already represents the workspace being the active + // node, so it doubles as the keyboard focus target representing the + // workspace as a whole. It lives inside the region (svgGroup_), so screen + // readers announce the region's label as enclosing context, then the focus + // target's stack count. Flyouts and mutators don't report a stack count, so + // their selection ring stays purely decorative. + if (!this.isFlyout && !this.isMutator) { + selectionRing.id = `${this.id}_focusTarget`; + this.workspaceFocusTargetElement = selectionRing; + this.workspaceFocusTarget = new WorkspaceFocusTarget(this, selectionRing); + } + this.layerManager = new LayerManager(this); // Assign the canvases for backwards compatibility. this.svgBlockCanvas_ = this.layerManager.getBlockLayer(); @@ -2741,9 +2787,29 @@ export class WorkspaceSvg /** See IFocusableNode.onNodeFocus. */ onNodeFocus(): void { - if (!this.isFlyout && !this.isMutator) { - this.updateAriaLabel(); - } + // This fires when the region itself is focused, e.g. by clicking the + // workspace background. Keyboard entry lands on the focus target instead (see + // handleWorkspaceFocusTargetFocus). + this.maybeAnnounceScreenreaderHint(); + } + + /** + * Handles the workspace's focus target receiving focus, which represents focus + * landing on the workspace as a whole. + * + * @internal + */ + handleWorkspaceFocusTargetFocus(): void { + // Keep the focus target's stack count fresh, since blocks may have been added or + // removed since it was last focused. + this.updateAriaLabel(); + this.maybeAnnounceScreenreaderHint(); + } + + /** + * Announces the screen reader hint the first time any workspace is focused. + */ + private maybeAnnounceScreenreaderHint(): void { if (!WorkspaceSvg.everFocused && !this.options.parentWorkspace) { aria.announceDynamicAriaState( Msg['SCREENREADER_HINT'].replace( @@ -2792,9 +2858,22 @@ export class WorkspaceSvg // Return the first block in the mutator workspace, if it exists. return this.getTopBlocks(true)[0] ?? null; } - // This workspace has never been focused before, so return null to use - // the default focusing behavior (focus the workspace itself). - return null; + // Entering the workspace with no previously focused node lands on the + // focus target (which announces the stack count) rather than the bare region. + return this.workspaceFocusTarget; + } + + /** + * Returns the focusable node representing this workspace as a whole, or null + * for workspaces without one (flyouts and mutators). + * + * Focus lands here when the workspace itself is focused (e.g. via the focus + * workspace shortcut) rather than on any of its contents. + * + * @returns This workspace's focus target node, if any. + */ + getWorkspaceFocusTarget(): WorkspaceFocusTarget | null { + return this.workspaceFocusTarget; } /** See IFocusableTree.getNestedTrees. */ @@ -2839,6 +2918,12 @@ export class WorkspaceSvg /** See IFocusableTree.lookUpFocusableNode. */ lookUpFocusableNode(id: string): IFocusableNode | null { + if ( + this.workspaceFocusTargetElement && + this.workspaceFocusTargetElement.id === id + ) { + return this.workspaceFocusTarget; + } // Check against flyout items if this workspace is part of a flyout. Note // that blocks may match against this pass before reaching getBlockById() // below (but only for a flyout workspace). @@ -2961,12 +3046,9 @@ export class WorkspaceSvg _node: IFocusableNode, _previousTree: IFocusableTree | null, ): void { - // Screen readers read this label as the enclosing region when workspace - // contents are focused, so refresh it here to keep the stack count from - // going stale. - if (!this.isFlyout && !this.isMutator) { - this.updateAriaLabel(); - } + // Refresh the focus target's stack count whenever focus enters the workspace so + // that it isn't stale if the user navigates back up to the focus target. + this.updateAriaLabel(); } /** See IFocusableTree.onTreeBlur. */ diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 0b562a2311f..71c9824f5b9 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -499,8 +499,9 @@ "KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.", "KEYBOARD_NAV_CUT_HINT": "Cut. Press %1 to paste.", "WORKSPACE_LABEL_PLAIN": "Blocks workspace.", - "WORKSPACE_LABEL_1_STACK": "Blocks workspace. 1 stack of blocks", - "WORKSPACE_LABEL_MANY_STACKS": "Blocks workspace. %1 stacks of blocks", + "WORKSPACE_ROLEDESCRIPTION": "workspace", + "WORKSPACE_LABEL_1_STACK": "1 stack of blocks", + "WORKSPACE_LABEL_MANY_STACKS": "%1 stacks of blocks", "WORKSPACE_LABEL_MUTATOR_WORKSPACE": "Block editor workspace", "WORKSPACE_LABEL_FLYOUT_WORKSPACE": "%1 blocks", "WORKSPACE_CONTENTS_BLOCKS_MANY": "%1 stacks of blocks%2 in workspace.", diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 3cc99d8f463..18277b9fb0c 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -493,8 +493,9 @@ "KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.", "KEYBOARD_NAV_CUT_HINT": "Message shown when an item is cut in keyboard navigation mode.", "WORKSPACE_LABEL_PLAIN": "Aria label for a workspace. Avoid using the name 'Blockly' as this could appear in branded products.", - "WORKSPACE_LABEL_1_STACK": "Aria label for a workspace with one stack of blocks.", - "WORKSPACE_LABEL_MANY_STACKS": "Aria label for a workspace with 0 or >1 stacks of blocks. \n\nParameters:\n* %1 - the number of stacks of blocks. A stack of blocks is a group of connected blocks that are not connected to any other blocks. 0 stacks means there are no blocks on the workspace.", + "WORKSPACE_ROLEDESCRIPTION": "Aria role description for the workspace, announced alongside the stack count when the workspace itself is focused.", + "WORKSPACE_LABEL_1_STACK": "Aria label announcing that the workspace contains one stack of blocks. Announced after the 'Blocks workspace.' region label when the workspace itself is focused, so it should not repeat that text.", + "WORKSPACE_LABEL_MANY_STACKS": "Aria label announcing how many stacks of blocks the workspace contains, for 0 or >1 stacks. Announced after the 'Blocks workspace.' region label when the workspace itself is focused, so it should not repeat that text. \n\nParameters:\n* %1 - the number of stacks of blocks. A stack of blocks is a group of connected blocks that are not connected to any other blocks. 0 stacks means there are no blocks on the workspace.", "WORKSPACE_LABEL_MUTATOR_WORKSPACE": "Aria label for a mutator workspace, which is a secondary workspace used for editing a block's structure. This type of workspace appears when a user clicks on the gear icon of a block that has a mutator, and allows the user to add, remove, or rearrange inputs to that block.", "WORKSPACE_LABEL_FLYOUT_WORKSPACE": "Aria label for an always-open flyout's workspace. Since the flyout will have a role of list, the resulting screenreader output will be something like 'Logic blocks list, with 5 items'. Do not include the word 'list' in this message. \n\nParameters:\n* %1 - the category of blocks in the flyout, e.g. 'Logic' or 'Math'. This may be empty for an uncategorized flyout.", "WORKSPACE_CONTENTS_BLOCKS_MANY": "ARIA live region message announcing the number of stacks of blocks in the workspace, optionally including comments. \n\nParameters:\n* %1 - the number of stacks (integer greater than 1)\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* '5 stacks of blocks in workspace.'\n* '5 stacks of blocks and 2 comments in workspace.'", diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index e25d9955aa7..d7bcc41adcd 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1933,13 +1933,16 @@ Blockly.Msg.KEYBOARD_NAV_CUT_HINT = 'Cut. Press %1 to paste.'; /// Aria label for a workspace. Avoid using the name "Blockly" as this could appear in branded products. Blockly.Msg.WORKSPACE_LABEL_PLAIN = 'Blocks workspace.'; /** @type {string} */ -/// Aria label for a workspace with one stack of blocks. -Blockly.Msg.WORKSPACE_LABEL_1_STACK = 'Blocks workspace. 1 stack of blocks'; +/// Aria role description for the workspace, announced alongside the stack count when the workspace itself is focused. +Blockly.Msg.WORKSPACE_ROLEDESCRIPTION = 'workspace'; /** @type {string} */ -/// Aria label for a workspace with 0 or >1 stacks of blocks. +/// Aria label announcing that the workspace contains one stack of blocks. Announced after the "Blocks workspace." region label when the workspace itself is focused, so it should not repeat that text. +Blockly.Msg.WORKSPACE_LABEL_1_STACK = '1 stack of blocks'; +/** @type {string} */ +/// Aria label announcing how many stacks of blocks the workspace contains, for 0 or >1 stacks. Announced after the "Blocks workspace." region label when the workspace itself is focused, so it should not repeat that text. /// \n\nParameters:\n* %1 - the number of stacks of blocks. A stack of blocks is a group of connected /// blocks that are not connected to any other blocks. 0 stacks means there are no blocks on the workspace. -Blockly.Msg.WORKSPACE_LABEL_MANY_STACKS = 'Blocks workspace. %1 stacks of blocks'; +Blockly.Msg.WORKSPACE_LABEL_MANY_STACKS = '%1 stacks of blocks'; /** @type {string} */ /// Aria label for a mutator workspace, which is a secondary workspace used for editing a block's structure. /// This type of workspace appears when a user clicks on the gear icon of a block that has a mutator, and diff --git a/packages/blockly/tests/mocha/block_test.js b/packages/blockly/tests/mocha/block_test.js index 4d6670dcb7e..9762f466ce5 100644 --- a/packages/blockly/tests/mocha/block_test.js +++ b/packages/blockly/tests/mocha/block_test.js @@ -3014,9 +3014,11 @@ suite('Blocks', function () { block.dispose(); this.clock.runAll(); + // Focus falls back to the workspace as a whole, which is represented by + // its focus target (so screen readers announce the updated stack count). assert.strictEqual( Blockly.getFocusManager().getFocusedNode(), - this.workspace, + this.workspace.getWorkspaceFocusTarget(), 'Focus should move to the workspace when the focused block is deleted', ); }); @@ -3066,7 +3068,7 @@ suite('Blocks', function () { } assert.strictEqual( Blockly.getFocusManager().getFocusedNode(), - this.workspace, + this.workspace.getWorkspaceFocusTarget(), 'Focus should move to the workspace, not a dying peer block', ); diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 78f11de3baf..f59476e0245 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -794,15 +794,17 @@ suite('Keyboard Shortcut Items', function () { ); const event = createKeyDownEvent(Blockly.utils.KeyCodes.W); this.workspace.getInjectionDiv().dispatchEvent(event); + // Focusing the workspace lands on its focus target, which announces the + // stack count, rather than on the workspace region itself. assert.strictEqual( Blockly.getFocusManager().getFocusedNode(), - this.workspace, + this.workspace.getWorkspaceFocusTarget(), ); }; }); test('Does not change focus when workspace is already focused', function () { - this.testFocusChange(this.workspace); + this.testFocusChange(this.workspace.getWorkspaceFocusTarget()); }); test('Focuses workspace when toolbox is focused', function () { diff --git a/packages/blockly/tests/mocha/workspace_svg_test.js b/packages/blockly/tests/mocha/workspace_svg_test.js index af191b418a7..4bfc706f916 100644 --- a/packages/blockly/tests/mocha/workspace_svg_test.js +++ b/packages/blockly/tests/mocha/workspace_svg_test.js @@ -150,11 +150,11 @@ suite('WorkspaceSvg', function () { }); suite('getRestoredFocusableNode', function () { - test('restores focus to the workspace itself for a non-mutator non-flyout workspace', function () { + test('restores focus to the workspace focus target for a non-mutator non-flyout workspace', function () { Blockly.getFocusManager().focusTree(this.workspace); assert.strictEqual( Blockly.getFocusManager().getFocusedNode(), - this.workspace, + this.workspace.getWorkspaceFocusTarget(), ); });