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
16 changes: 14 additions & 2 deletions packages/blockly/core/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ input[type=number] {
.blocklyActiveFocus:is(
.blocklyFlyout,
.blocklyWorkspace,
.blocklyWorkspaceSelectionRing,
.blocklyField,
.blocklyPath,
.blocklyHighlightedConnectionPath,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion packages/blockly/core/shortcut_items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
64 changes: 64 additions & 0 deletions packages/blockly/core/workspace_focus_target.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
120 changes: 101 additions & 19 deletions packages/blockly/core/workspace_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)),
);
Expand Down Expand Up @@ -801,14 +834,15 @@ export class WorkspaceSvg
}
}

this.workspaceSelectionRing = dom.createSvgElement(
const selectionRing = dom.createSvgElement(
Svg.RECT,
{
fill: 'none',
class: 'blocklyWorkspaceSelectionRing',
},
this.svgGroup_,
);
this.workspaceSelectionRing = selectionRing;
this.workspaceFocusRing = dom.createSvgElement(
Svg.RECT,
{
Expand All @@ -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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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. */
Expand Down
5 changes: 3 additions & 2 deletions packages/blockly/msg/json/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 3 additions & 2 deletions packages/blockly/msg/json/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.'",
Expand Down
11 changes: 7 additions & 4 deletions packages/blockly/msg/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions packages/blockly/tests/mocha/block_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
Expand Down Expand Up @@ -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',
);

Expand Down
Loading