From 372ef7c5725c35c9159a81297c5ee0998f0093d7 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 19 Jun 2026 12:57:32 +0100 Subject: [PATCH 1/3] fix: Announce the workspace to VoiceOver via a focus target node VoiceOver doesn't announce a region when focus moves into it from a node already inside that region, so focusing the workspace region directly (e.g. pressing "W" from a block) was silent. Introduce WorkspaceFocusTarget: an ordinary focusable node that represents the workspace as a whole. The workspace's selection ring (which already represents the workspace being the active node) doubles as this target, gaining role=figure, aria-roledescription="workspace" and the stack count. Focusing the workspace now lands here instead of the region, so the move is announced. The region keeps a short, stable label ("Blocks workspace.") as enclosing context, and the stack count moves off the region onto the target. - W shortcut and fresh-entry (getRestoredFocusableNode) focus the target. - Add WorkspaceFocusTargetNavigationPolicy so navigating in reaches blocks. - Block/comment navigation is unchanged. --- packages/blockly/core/css.ts | 16 ++- ...orkspace_focus_target_navigation_policy.ts | 89 +++++++++++++ .../core/keyboard_nav/navigators/navigator.ts | 2 + packages/blockly/core/shortcut_items.ts | 7 +- .../blockly/core/workspace_focus_target.ts | 64 ++++++++++ packages/blockly/core/workspace_svg.ts | 120 +++++++++++++++--- packages/blockly/msg/json/en.json | 7 +- packages/blockly/msg/json/qqq.json | 14 +- packages/blockly/msg/messages.js | 11 +- packages/blockly/tests/mocha/block_test.js | 6 +- .../tests/mocha/shortcut_items_test.js | 6 +- .../blockly/tests/mocha/workspace_svg_test.js | 4 +- 12 files changed, 300 insertions(+), 46 deletions(-) create mode 100644 packages/blockly/core/keyboard_nav/navigation_policies/workspace_focus_target_navigation_policy.ts create mode 100644 packages/blockly/core/workspace_focus_target.ts 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/keyboard_nav/navigation_policies/workspace_focus_target_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_focus_target_navigation_policy.ts new file mode 100644 index 00000000000..3fda051cb99 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_focus_target_navigation_policy.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {WorkspaceFocusTarget} from '../../workspace_focus_target.js'; + +/** + * Set of rules controlling keyboard navigation from the workspace focus target. + * + * The focus target represents the workspace as a whole (reached via the focus + * workspace shortcut or when first entering the workspace), so navigating into + * it should mirror navigating into the workspace itself. + */ +export class WorkspaceFocusTargetNavigationPolicy implements INavigationPolicy { + /** + * Returns the first child of the focus target's workspace. + * + * @param current The focus target to return the first child of. + * @returns The top block of the first block stack, if any. + */ + getFirstChild(current: WorkspaceFocusTarget): IFocusableNode | null { + const blocks = current.getWorkspace().getTopBlocks(true); + return blocks.length ? blocks[0] : null; + } + + /** + * Returns the parent of the given focus target. + * + * @param _current The focus target to return the parent of. + * @returns Null. + */ + getParent(_current: WorkspaceFocusTarget): IFocusableNode | null { + return null; + } + + /** + * Returns the next sibling of the given focus target. + * + * @param _current The focus target to return the next sibling of. + * @returns Null. + */ + getNextSibling(_current: WorkspaceFocusTarget): IFocusableNode | null { + return null; + } + + /** + * Returns the previous sibling of the given focus target. + * + * @param _current The focus target to return the previous sibling of. + * @returns Null. + */ + getPreviousSibling(_current: WorkspaceFocusTarget): IFocusableNode | null { + return null; + } + + /** + * Returns the row ID of the given focus target. + * + * @param current The focus target to retrieve the row ID of. + * @returns The row ID of the focus target's workspace. + */ + getRowId(current: WorkspaceFocusTarget): string { + return current.getWorkspace().id; + } + + /** + * Returns whether or not the given focus target can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given focus target can be focused. + */ + isNavigable(current: WorkspaceFocusTarget): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a WorkspaceFocusTarget. + */ + isApplicable(current: any): current is WorkspaceFocusTarget { + return current instanceof WorkspaceFocusTarget; + } +} diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts index 785f5e46075..b001c8cb347 100644 --- a/packages/blockly/core/keyboard_nav/navigators/navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -20,6 +20,7 @@ import {ConnectionNavigationPolicy} from '../navigation_policies/connection_navi import {FieldNavigationPolicy} from '../navigation_policies/field_navigation_policy.js'; import {IconNavigationPolicy} from '../navigation_policies/icon_navigation_policy.js'; import {WorkspaceCommentNavigationPolicy} from '../navigation_policies/workspace_comment_navigation_policy.js'; +import {WorkspaceFocusTargetNavigationPolicy} from '../navigation_policies/workspace_focus_target_navigation_policy.js'; import {WorkspaceNavigationPolicy} from '../navigation_policies/workspace_navigation_policy.js'; type RuleList = INavigationPolicy[]; @@ -48,6 +49,7 @@ export class Navigator { new FieldNavigationPolicy(), new ConnectionNavigationPolicy(), new WorkspaceNavigationPolicy(), + new WorkspaceFocusTargetNavigationPolicy(), new IconNavigationPolicy(), new WorkspaceCommentNavigationPolicy(), new CommentBarButtonNavigationPolicy(), diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 3be97e82eeb..9cd0c073399 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -774,7 +774,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..5bedc375c93 --- /dev/null +++ b/packages/blockly/core/workspace_focus_target.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Google LLC + * 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 56dfc0f4fad..8bfe838eae2 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -95,6 +95,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. */ @@ -346,6 +347,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. @@ -736,26 +751,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)), ); @@ -808,7 +841,7 @@ export class WorkspaceSvg } } - this.workspaceSelectionRing = dom.createSvgElement( + const selectionRing = dom.createSvgElement( Svg.RECT, { fill: 'none', @@ -816,6 +849,7 @@ export class WorkspaceSvg }, this.svgGroup_, ); + this.workspaceSelectionRing = selectionRing; this.workspaceFocusRing = dom.createSvgElement( Svg.RECT, { @@ -825,6 +859,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(); @@ -2748,9 +2794,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( @@ -2799,9 +2865,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. */ @@ -2846,6 +2925,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). @@ -2968,12 +3053,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 4e563f18e0a..ce7b0af1863 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-06-12 09:20:03.058537", + "lastupdated": "2026-06-19 12:15:19.173460", "locale": "en", "messagedocumentation" : "qqq" }, @@ -493,8 +493,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 b1afab63ad3..a46a6260f95 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -1,13 +1,4 @@ { - "@metadata": { - "authors": [ - "Ajeje Brazorf", - "Espertus", - "Liuxinyu970226", - "McDutchie", - "Shirayuki" - ] - }, "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}}", @@ -496,8 +487,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 80f8f950bce..28be0008413 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1915,13 +1915,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 f22f9f58e8d..1a9555b5e5e 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(), ); }); From 4adc0fc0643ab1deed4f3315cb2d3e3985607d7c Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 29 Jun 2026 20:00:28 +0100 Subject: [PATCH 2/3] Remove policy that turns out not to be needed --- ...orkspace_focus_target_navigation_policy.ts | 89 ------------------- .../core/keyboard_nav/navigators/navigator.ts | 2 - 2 files changed, 91 deletions(-) delete mode 100644 packages/blockly/core/keyboard_nav/navigation_policies/workspace_focus_target_navigation_policy.ts diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/workspace_focus_target_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_focus_target_navigation_policy.ts deleted file mode 100644 index 3fda051cb99..00000000000 --- a/packages/blockly/core/keyboard_nav/navigation_policies/workspace_focus_target_navigation_policy.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; -import {WorkspaceFocusTarget} from '../../workspace_focus_target.js'; - -/** - * Set of rules controlling keyboard navigation from the workspace focus target. - * - * The focus target represents the workspace as a whole (reached via the focus - * workspace shortcut or when first entering the workspace), so navigating into - * it should mirror navigating into the workspace itself. - */ -export class WorkspaceFocusTargetNavigationPolicy implements INavigationPolicy { - /** - * Returns the first child of the focus target's workspace. - * - * @param current The focus target to return the first child of. - * @returns The top block of the first block stack, if any. - */ - getFirstChild(current: WorkspaceFocusTarget): IFocusableNode | null { - const blocks = current.getWorkspace().getTopBlocks(true); - return blocks.length ? blocks[0] : null; - } - - /** - * Returns the parent of the given focus target. - * - * @param _current The focus target to return the parent of. - * @returns Null. - */ - getParent(_current: WorkspaceFocusTarget): IFocusableNode | null { - return null; - } - - /** - * Returns the next sibling of the given focus target. - * - * @param _current The focus target to return the next sibling of. - * @returns Null. - */ - getNextSibling(_current: WorkspaceFocusTarget): IFocusableNode | null { - return null; - } - - /** - * Returns the previous sibling of the given focus target. - * - * @param _current The focus target to return the previous sibling of. - * @returns Null. - */ - getPreviousSibling(_current: WorkspaceFocusTarget): IFocusableNode | null { - return null; - } - - /** - * Returns the row ID of the given focus target. - * - * @param current The focus target to retrieve the row ID of. - * @returns The row ID of the focus target's workspace. - */ - getRowId(current: WorkspaceFocusTarget): string { - return current.getWorkspace().id; - } - - /** - * Returns whether or not the given focus target can be navigated to. - * - * @param current The instance to check for navigability. - * @returns True if the given focus target can be focused. - */ - isNavigable(current: WorkspaceFocusTarget): boolean { - return current.canBeFocused(); - } - - /** - * Returns whether the given object can be navigated from by this policy. - * - * @param current The object to check if this policy applies to. - * @returns True if the object is a WorkspaceFocusTarget. - */ - isApplicable(current: any): current is WorkspaceFocusTarget { - return current instanceof WorkspaceFocusTarget; - } -} diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts index b001c8cb347..785f5e46075 100644 --- a/packages/blockly/core/keyboard_nav/navigators/navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -20,7 +20,6 @@ import {ConnectionNavigationPolicy} from '../navigation_policies/connection_navi import {FieldNavigationPolicy} from '../navigation_policies/field_navigation_policy.js'; import {IconNavigationPolicy} from '../navigation_policies/icon_navigation_policy.js'; import {WorkspaceCommentNavigationPolicy} from '../navigation_policies/workspace_comment_navigation_policy.js'; -import {WorkspaceFocusTargetNavigationPolicy} from '../navigation_policies/workspace_focus_target_navigation_policy.js'; import {WorkspaceNavigationPolicy} from '../navigation_policies/workspace_navigation_policy.js'; type RuleList = INavigationPolicy[]; @@ -49,7 +48,6 @@ export class Navigator { new FieldNavigationPolicy(), new ConnectionNavigationPolicy(), new WorkspaceNavigationPolicy(), - new WorkspaceFocusTargetNavigationPolicy(), new IconNavigationPolicy(), new WorkspaceCommentNavigationPolicy(), new CommentBarButtonNavigationPolicy(), From ea59d4562162a1dcda27034174a1ed5a0102cc1e Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 29 Jun 2026 20:46:33 +0100 Subject: [PATCH 3/3] chore: fix copyright notice --- packages/blockly/core/workspace_focus_target.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/core/workspace_focus_target.ts b/packages/blockly/core/workspace_focus_target.ts index 5bedc375c93..dda96dee946 100644 --- a/packages/blockly/core/workspace_focus_target.ts +++ b/packages/blockly/core/workspace_focus_target.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Raspberry Pi Foundation * SPDX-License-Identifier: Apache-2.0 */