Repository: google/blockly
Pull request #8916: feat: Make WorkspaceSvg and BlockSvg focusable
Author: login: BenHenning
Labels: name: PR: feature
description: Adds a feature
Created: 2025-04-21T23:46:13Z | Merged: 2025-04-24T21:48:16Z

## The basics

- [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)

## The details
### Resolves

Fixes #8913
Fixes #8914
Fixes part of #8771

### Proposed Changes

This updates `WorkspaceSvg` and `BlockSvg` to be focusable, that is, it makes the workspace a `IFocusableTree` and blocks `IFocusableNode`s.

Some important details:
- While this introduces focusable tree support for `Workspace` it doesn't include two other components that are obviously needed by the keyboard navigation plugin's playground: fields and connections. These will be introduced in subsequent PRs.
- Blocks are set up to automatically synchronize their selection state with their focus state. This will eventually help to replace `LineCursor`'s responsibility for managing selection state itself.
- The tabindex property for the workspace and its ARIA label have been moved down to the `.blocklyWorkspace` element itself rather than its wrapper. This helps address some tab stop issues that are already addressed in the plugin (via monkey patches), but also to ensure that the workspace's main SVG group interacts correctly with `FocusManager`.
- `WorkspaceSvg` is being initially set up to default to its first top block when being focused for the first time. This is to match parity with the keyboard navigation plugin, however the latter also has functionality for defaulting to a position when no blocks are present. It's not clear how to actually support this under the new focus-based system (without adding an ephemeral element on which to focus), or if it's even necessary (since the workspace root can hold focus).

### Reason for Changes

This is part of an ongoing effort to ensure key components of Blockly are focusable so that they can be keyboard-navigable (with other needed changes yet both in Core Blockly and the keyboard navigation plugin).

### Test Coverage

No new tests have been added. It's certainly possible to add unit tests for the focusable configurations being introduced in this PR, but it may not be highly beneficial. It's largely assumed that the individual implementations should work due to a highly tested FocusManager, and it may be the case that the interactions of the components working together is far more important to verify (that is, the end user flows). The latter is planned to be tackled as part of #8915.

### Documentation

No documentation changes should be needed here.

### Additional Information

This includes changes that have been pulled from #8875.


Linked issue #8913: Workspace.
Linked issue comments: user: login: BenHenning
created_at: 2025-04-24T22:12:04Z
body: Fixed in #8916.
author_association: COLLABORATOR
user: login: BenHenning
created_at: 2025-04-29T19:43:21Z
body: Reverted in #8933.
author_association: COLLABORATOR
user: login: BenHenning
created_at: 2025-04-30T22:44:14Z
body: Fixed in #8938.
author_association: COLLABORATOR

Diff:
diff --git a/core/block_svg.ts b/core/block_svg.ts
index b8712b01914..8bc0b7af3f6 100644
--- a/core/block_svg.ts
+++ b/core/block_svg.ts
@@ -44,6 +44,8 @@ import {IContextMenu} from './interfaces/i_contextmenu.js';
 import type {ICopyable} from './interfaces/i_copyable.js';
 import {IDeletable} from './interfaces/i_deletable.js';
 import type {IDragStrategy, IDraggable} from './interfaces/i_draggable.js';
+import type {IFocusableNode} from './interfaces/i_focusable_node.js';
+import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
 import {IIcon} from './interfaces/i_icon.js';
 import * as internalConstants from './internal_constants.js';
 import {MarkerManager} from './marker_manager.js';
@@ -76,7 +78,8 @@ export class BlockSvg
     IContextMenu,
     ICopyable<BlockCopyData>,
     IDraggable,
-    IDeletable
+    IDeletable,
+    IFocusableNode
 {
   /**
    * Constant for identifying rows that are to be rendered inline.
@@ -210,6 +213,7 @@ export class BlockSvg
 
     // Expose this block's ID on its top-level SVG group.
     this.svgGroup.setAttribute('data-id', this.id);
+    svgPath.id = this.id;
 
     this.doInit_();
   }
@@ -1819,4 +1823,26 @@ export class BlockSvg
       );
     }
   }
+
+  /** See IFocusableNode.getFocusableElement. */
+  getFocusableElement(): HTMLElement | SVGElement {
+    return this.pathObject.svgPath;
+  }
+
+  /** See IFocusableNode.getFocusableTree. */
+  getFocusableTree(): IFocusableTree {
+    return this.workspace;
+  }
+
+  /** See IFocusableNode.onNodeFocus. */
+  onNodeFocus(): void {
+    common.setSelected(this);
+  }
+
+  /** See IFocusableNode.onNodeBlur. */
+  onNodeBlur(): void {
+    if (common.getSelected() === this) {
+      common.setSelected(null);
+    }
+  }
 }
diff --git a/core/inject.ts b/core/inject.ts
index de78fbfae75..34d9c1795f8 100644
--- a/core/inject.ts
+++ b/core/inject.ts
@@ -13,13 +13,11 @@ import * as common from './common.js';
 import * as Css from './css.js';
 import * as dropDownDiv from './dropdowndiv.js';
 import {Grid} from './grid.js';
-import {Msg} from './msg.js';
 import {Options} from './options.js';
 import {ScrollbarPair} from './scrollbar_pair.js';
 import {ShortcutRegistry} from './shortcut_registry.js';
 import * as Tooltip from './tooltip.js';
 import * as Touch from './touch.js';
-import * as aria from './utils/aria.js';
 import * as dom from './utils/dom.js';
 import {Svg} from './utils/svg.js';
 import * as WidgetDiv from './widgetdiv.js';
@@ -56,8 +54,6 @@ export function inject(
   if (opt_options?.rtl) {
     dom.addClass(subContainer, 'blocklyRTL');
   }
-  subContainer.tabIndex = 0;
-  aria.setState(subContainer, aria.State.LABEL, Msg['WORKSPACE_ARIA_LABEL']);
 
   containerElement!.appendChild(subContainer);
   const svg = createDom(subContainer, options);
@@ -126,7 +122,6 @@ function createDom(container: HTMLElement, options: Options): SVGElement {
       'xmlns:xlink': dom.XLINK_NS,
       'version': '1.1',
       'class': 'blocklySvg',
-      'tabindex': '0',
     },
     container,
   );
diff --git a/core/renderers/common/path_object.ts b/core/renderers/common/path_object.ts
index 077f80bb741..72cf2a594ce 100644
--- a/core/renderers/common/path_object.ts
+++ b/core/renderers/common/path_object.ts
@@ -62,7 +62,7 @@ export class PathObject implements IPathObject {
     /** The primary path of the block. */
     this.svgPath = dom.createSvgElement(
       Svg.PATH,
-      {'class': 'blocklyPath'},
+      {'class': 'blocklyPath', 'tabindex': '-1'},
       this.svgRoot,
     );
 
diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts
index 91668b744d4..b1c96373771 100644
--- a/core/workspace_svg.ts
+++ b/core/workspace_svg.ts
@@ -37,6 +37,7 @@ import {EventType} from './events/type.js';
 import * as eventUtils from './events/utils.js';
 import {Flyout} from './flyout_base.js';
 import type {FlyoutButton} from './flyout_button.js';
+import {getFocusManager} from './focus_manager.js';
 import {Gesture} from './gesture.js';
 import {Grid} from './grid.js';
 import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
@@ -44,6 +45,8 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js';
 import {IContextMenu} from './interfaces/i_contextmenu.js';
 import type {IDragTarget} from './interfaces/i_drag_target.js';
 import type {IFlyout} from './interfaces/i_flyout.js';
+import type {IFocusableNode} from './interfaces/i_focusable_node.js';
+import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
 import type {IMetricsManager} from './interfaces/i_metrics_manager.js';
 import type {IToolbox} from './interfaces/i_toolbox.js';
 import type {
@@ -54,6 +57,7 @@ import type {LineCursor} from './keyboard_nav/line_cursor.js';
 import type {Marker} from './keyboard_nav/marker.js';
 import {LayerManager} from './layer_manager.js';
 import {MarkerManager} from './marker_manager.js';
+import {Msg} from './msg.js';
 import {Options} from './options.js';
 import * as Procedures from './procedures.js';
 import * as registry from './registry.js';
@@ -66,6 +70,7 @@ import {Classic} from './theme/classic.js';
 import {ThemeManager} from './theme_manager.js';
 import * as Tooltip from './tooltip.js';
 import type {Trashcan} from './trashcan.js';
+import * as aria from './utils/aria.js';
 import * as arrayUtils from './utils/array.js';
 import {Coordinate} from './utils/coordinate.js';
 import * as dom from './utils/dom.js';
@@ -93,7 +98,7 @@ const ZOOM_TO_FIT_MARGIN = 20;
  */
 export class WorkspaceSvg
   extends Workspace
-  implements IASTNodeLocationSvg, IContextMenu
+  implements IASTNodeLocationSvg, IContextMenu, IFocusableNode, IFocusableTree
 {
   /**
    * A wrapper function called when a resize event occurs.
@@ -764,7 +769,19 @@ export class WorkspaceSvg
      *   <g class="blocklyBubbleCanvas"></g>
      * </g>
      */
-    this.svgGroup_ = dom.createSvgElement(Svg.G, {'class': 'blocklyWorkspace'});
+    this.svgGroup_ = dom.createSvgElement(Svg.G, {
+      'class': 'blocklyWorkspace',
+      // Only the top-level workspace should be tabbable.
+      'tabindex': injectionDiv ? '0' : '-1',
+      'id': this.id,
+    });
+    if (injectionDiv) {
+      aria.setState(
+        this.svgGroup_,
+        aria.State.LABEL,
+        Msg['WORKSPACE_ARIA_LABEL'],
+      );
+    }
 
     // Note that a <g> alone does not receive mouse events--it must have a
     // valid target inside it.  If no background class is specified, as in the
@@ -840,6 +857,9 @@ export class WorkspaceSvg
       this.getTheme(),
       isParentWorkspace ? this.getInjectionDiv() : undefined,
     );
+
+    getFocusManager().registerTree(this);
+
     return this.svgGroup_;
   }
 
@@ -924,6 +944,10 @@ export class WorkspaceSvg
       document.body.removeEventListener('wheel', this.dummyWheelListener);
       this.dummyWheelListener = null;
     }
+
+    if (getFocusManager().isRegistered(this)) {
+      getFocusManager().unregisterTree(this);
+    }
   }
 
   /**
@@ -2618,6 +2642,55 @@ export class WorkspaceSvg
     deltaY *= scale;
     this.scroll(this.scrollX + deltaX, this.scrollY + deltaY);
   }
+
+  /** See IFocusableNode.getFocusableElement. */
+  getFocusableElement(): HTMLElement | SVGElement {
+    return this.svgGroup_;
+  }
+
+  /** See IFocusableNode.getFocusableTree. */
+  getFocusableTree(): IFocusableTree {
+    return this;
+  }
+
+  /** See IFocusableNode.onNodeFocus. */
+  onNodeFocus(): void {}
+
+  /** See IFocusableNode.onNodeBlur. */
+  onNodeBlur(): void {}
+
+  /** See IFocusableTree.getRootFocusableNode. */
+  getRootFocusableNode(): IFocusableNode {
+    return this;
+  }
+
+  /** See IFocusableTree.getRestoredFocusableNode. */
+  getRestoredFocusableNode(
+    previousNode: IFocusableNode | null,
+  ): IFocusableNode | null {
+    if (!previousNode) {
+      return this.getTopBlocks(true)[0] ?? null;
+    } else return null;
+  }
+
+  /** See IFocusableTree.getNestedTrees. */
+  getNestedTrees(): Array<IFocusableTree> {
+    return [];
+  }
+
+  /** See IFocusableTree.lookUpFocusableNode. */
+  lookUpFocusableNode(id: string): IFocusableNode | null {
+    return this.getBlockById(id) as IFocusableNode;
+  }
+
+  /** See IFocusableTree.onTreeFocus. */
+  onTreeFocus(
+    _node: IFocusableNode,
+    _previousTree: IFocusableTree | null,
+  ): void {}
+
+  /** See IFocusableTree.onTreeBlur. */
+  onTreeBlur(_nextTree: IFocusableTree | null): void {}
 }
 
 /**


Reviews:
user: login: BenHenning
body: Self-reviewed changes that aren't part of #8909.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-21T23:52:55Z
user: login: gonfunko
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T18:24:51Z
user: login: rachel-fenichel
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T19:22:47Z
user: login: BenHenning
body: Self-reviewed latest changes & followed up to review comments.

Note that #8909 has been merged so this PR is only the changes related to workspace & blocks. Subsequently, I realized I forgot to include the tab index change to make blocks focusable, so that's been fixed in the latest changes.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T22:27:19Z
user: login: rachel-fenichel
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T16:06:38Z
user: login: BenHenning
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T21:30:43Z
user: login: BenHenning
body: Self-reviewed latest brances change.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T21:38:25Z
user: login: rachel-fenichel
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-24T21:46:27Z

Review comments:
diff_hunk: @@ -764,7 +769,19 @@ export class WorkspaceSvg
      *   <g class="blocklyBubbleCanvas"></g>
      * </g>
      */
-    this.svgGroup_ = dom.createSvgElement(Svg.G, {'class': 'blocklyWorkspace'});
+    this.svgGroup_ = dom.createSvgElement(Svg.G, {
+      'class': 'blocklyWorkspace',
+      // Only the main workspace should be tabbable.
path: core/workspace_svg.ts
user: login: rachel-fenichel
body: Say "top level" rather than "main", because `Blockly.getMainWorkspace()` already exists and refers to the currently active workspace.

From context, I'm assuming that the distinction is between top level and flyout or mutator workspaces.
created_at: 2025-04-23T16:15:02Z
author_association: COLLABORATOR
diff_hunk: @@ -1819,4 +1823,28 @@ export class BlockSvg
       );
     }
   }
+
+  /** See IFocusableNode.getFocusableElement. */
+  getFocusableElement(): HTMLElement | SVGElement {
+    return this.pathObject.svgPath;
+  }
+
+  /** See IFocusableNode.getFocusableTree. */
+  getFocusableTree(): IFocusableTree {
+    return this.workspace;
+  }
+
+  /** See IFocusableNode.onNodeFocus. */
+  onNodeFocus(): void {
+    if (!this.isShadow()) {
path: core/block_svg.ts
user: login: rachel-fenichel
body: What happens if it is a shadow block?
created_at: 2025-04-23T19:21:55Z
author_association: COLLABORATOR
diff_hunk: @@ -1819,4 +1823,28 @@ export class BlockSvg
       );
     }
   }
+
+  /** See IFocusableNode.getFocusableElement. */
+  getFocusableElement(): HTMLElement | SVGElement {
+    return this.pathObject.svgPath;
+  }
+
+  /** See IFocusableNode.getFocusableTree. */
+  getFocusableTree(): IFocusableTree {
+    return this.workspace;
+  }
+
+  /** See IFocusableNode.onNodeFocus. */
+  onNodeFocus(): void {
+    if (!this.isShadow()) {
path: core/block_svg.ts
user: login: BenHenning
body: This was actually copying https://github.com/google/blockly/blob/ac7fea11cffc4c445cedae77675290861ceec2f6/core/keyboard_nav/line_cursor.ts#L652-L657 but through a lot of code transforming. In retrospect, this is probably wrong.

To be fair, I'm not 100% certain how shadow blocks will behave and that will need testing. The field will receive focus, and the shadow block might be focusable if clicked on but will never otherwise be navigated to per the cursor's behavior (I think). So this should be safe to remove and just call setSelected() directly.
created_at: 2025-04-23T21:48:54Z
author_association: COLLABORATOR
diff_hunk: @@ -764,7 +769,19 @@ export class WorkspaceSvg
      *   <g class="blocklyBubbleCanvas"></g>
      * </g>
      */
-    this.svgGroup_ = dom.createSvgElement(Svg.G, {'class': 'blocklyWorkspace'});
+    this.svgGroup_ = dom.createSvgElement(Svg.G, {
+      'class': 'blocklyWorkspace',
+      // Only the main workspace should be tabbable.
path: core/workspace_svg.ts
user: login: BenHenning
body: That's correct--fixed!
created_at: 2025-04-23T22:25:48Z
author_association: COLLABORATOR
diff_hunk: @@ -1819,4 +1823,28 @@ export class BlockSvg
       );
     }
   }
+
+  /** See IFocusableNode.getFocusableElement. */
+  getFocusableElement(): HTMLElement | SVGElement {
+    return this.pathObject.svgPath;
+  }
+
+  /** See IFocusableNode.getFocusableTree. */
+  getFocusableTree(): IFocusableTree {
+    return this.workspace;
+  }
+
+  /** See IFocusableNode.onNodeFocus. */
+  onNodeFocus(): void {
+    if (!this.isShadow()) {
path: core/block_svg.ts
user: login: rachel-fenichel
body: I'm also not sure. I think call `setSelected` directly here and do some manual verification, then file a ticket to follow up with comprehensive tests for that case.
created_at: 2025-04-24T16:06:38Z
author_association: COLLABORATOR
diff_hunk: @@ -1819,4 +1823,28 @@ export class BlockSvg
       );
     }
   }
+
+  /** See IFocusableNode.getFocusableElement. */
+  getFocusableElement(): HTMLElement | SVGElement {
+    return this.pathObject.svgPath;
+  }
+
+  /** See IFocusableNode.getFocusableTree. */
+  getFocusableTree(): IFocusableTree {
+    return this.workspace;
+  }
+
+  /** See IFocusableNode.onNodeFocus. */
+  onNodeFocus(): void {
+    if (!this.isShadow()) {
path: core/block_svg.ts
user: login: BenHenning
body: So far I'm not seeing any issues, so I don't think there's a particular concern to address.

I've made a note in #8915 regarding this specific case.
created_at: 2025-04-24T21:30:43Z
author_association: COLLABORATOR

PR comments:
user: login: BenHenning
created_at: 2025-04-21T23:55:09Z
body: PTAL @gonfunko. FYI this includes changes from #8909 which can be ignored (there are no additional changes in those files in this PR).
author_association: COLLABORATOR
user: login: BenHenning
created_at: 2025-04-23T22:27:42Z
body: PTAL @gonfunko if you would like to double check the latest changes.

PTAL @rachel-fenichel per your comments.
author_association: COLLABORATOR
user: login: BenHenning
created_at: 2025-04-24T21:47:55Z
body: Thanks all for the reviews. :) Going ahead and merging (with squash this time...).
author_association: COLLABORATOR