feat: Make WorkspaceSvg and BlockSvg focusable (#8916)

## 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.
This commit is contained in:
Ben Henning
2025-04-24 14:48:16 -07:00
committed by GitHub
parent ac7fea11cf
commit d7680cf32e
4 changed files with 103 additions and 9 deletions

View File

@@ -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);
}
}
}

View File

@@ -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,
);

View File

@@ -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,
);

View File

@@ -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 {}
}
/**