Skip to content

Commit 9b42948

Browse files
committed
fix: nest trashcan and zoom as separate focus trees
1 parent cf5c9f3 commit 9b42948

8 files changed

Lines changed: 260 additions & 46 deletions

File tree

packages/blockly/core/blockly.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ export * from './keyboard_nav/navigation_policies/workspace_comment_navigation_p
442442
export * from './keyboard_nav/navigation_policies/workspace_navigation_policy.js';
443443
export * from './keyboard_nav/navigators/flyout_navigator.js';
444444
export * from './keyboard_nav/navigators/navigator.js';
445+
export * from './keyboard_nav/navigators/workspace_control_navigator.js';
445446
export * from './toast.js';
446447

447448
// Re-export submodules that no longer declareLegacyNamespace.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Raspberry Pi Foundation
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {Navigator} from './navigator.js';
8+
9+
/**
10+
* No-op Navigator for single-node workspace controls (trashcan, zoom).
11+
*/
12+
export class WorkspaceControlNavigator extends Navigator {
13+
override getNextNode() {
14+
return null;
15+
}
16+
17+
override getPreviousNode() {
18+
return null;
19+
}
20+
21+
override getInNode() {
22+
return null;
23+
}
24+
25+
override getOutNode() {
26+
return null;
27+
}
28+
29+
override navigateStacks() {
30+
return null;
31+
}
32+
}

packages/blockly/core/sprites.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
/**
8-
* Contains the path to a single png tat holds the images for the trashcan
8+
* Contains the path to a single svg that holds the images for the trashcan
99
* as well as the zoom controls.
1010
*/
1111
export const SPRITE = {

packages/blockly/core/trashcan.ts

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ import type {IComponent} from './interfaces/i_component';
2626
import type {IDraggable} from './interfaces/i_draggable.js';
2727
import type {IFlyout} from './interfaces/i_flyout.js';
2828
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
29+
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
2930
import type {IPositionable} from './interfaces/i_positionable.js';
3031
import {KeyboardMover} from './keyboard_nav/keyboard_mover.js';
32+
import {WorkspaceControlNavigator} from './keyboard_nav/navigators/workspace_control_navigator.js';
3133
import {keyboardNavigationController} from './keyboard_navigation_controller.js';
3234
import type {UiMetrics} from './metrics_manager.js';
3335
import {Msg} from './msg.js';
@@ -50,8 +52,14 @@ import type {WorkspaceSvg} from './workspace_svg.js';
5052
*/
5153
export class Trashcan
5254
extends DeleteArea
53-
implements IAutoHideable, IPositionable, IFocusableNode, IComponent
55+
implements
56+
IAutoHideable,
57+
IPositionable,
58+
IFocusableNode,
59+
IFocusableTree,
60+
IComponent
5461
{
62+
private readonly navigator = new WorkspaceControlNavigator();
5563
/**
5664
* The id for this component that is used to register with the
5765
* ComponentManager.
@@ -137,17 +145,25 @@ export class Trashcan
137145
*/
138146
createDom(): SVGElement {
139147
/* Here's the markup that will be generated:
140-
<g class="blocklyTrash">
141-
<clippath id="blocklyTrashBodyClipPath837493">
142-
<rect width="47" height="45" y="15"></rect>
143-
</clippath>
144-
<image width="64" height="92" y="-32" xlink:href="media/sprites.png"
145-
clip-path="url(#blocklyTrashBodyClipPath837493)"></image>
146-
<clippath id="blocklyTrashLidClipPath837493">
147-
<rect width="47" height="15"></rect>
148-
</clippath>
149-
<image width="84" height="92" y="-32" xlink:href="media/sprites.png"
150-
clip-path="url(#blocklyTrashLidClipPath837493)"></image>
148+
<g role="button" class="blocklyTrash" tabindex="0" id="blockly-0"
149+
aria-label="Trash, currently empty" aria-disabled="true">
150+
<rect width="55" height="68" x="-4" y="-4" rx="2" ry="2"
151+
fill="none" class="blocklyFocusRing"></rect>
152+
<clipPath id="blocklyTrashBodyClipPath837493">
153+
<rect width="47" height="44" y="16"></rect>
154+
</clipPath>
155+
<image width="96" x="0" height="124" y="-32"
156+
clip-path="url(#blocklyTrashBodyClipPath837493)"
157+
xlink:href="../media/sprites.svg"></image>
158+
<clipPath id="blocklyTrashLidClipPath837493">
159+
<rect width="47" height="16"></rect>
160+
</clipPath>
161+
<g role="none" class="blocklyTrashLid">
162+
<svg role="none" viewBox="0 32 47 16" width="47" height="16">
163+
<image width="96" height="124"
164+
href="../media/sprites.svg"></image>
165+
</svg>
166+
</g>
151167
</g>
152168
*/
153169
this.svgGroup = dom.createSvgElement(Svg.G, {
@@ -256,6 +272,7 @@ export class Trashcan
256272
this.blockMouseDownWhenOpenable,
257273
);
258274
browserEvents.bind(this.svgGroup, 'pointerup', this, this.click);
275+
getFocusManager().registerTree(this, false);
259276
return this.svgGroup;
260277
}
261278

@@ -275,7 +292,6 @@ export class Trashcan
275292
ComponentManager.Capability.DELETE_AREA,
276293
ComponentManager.Capability.DRAG_TARGET,
277294
ComponentManager.Capability.POSITIONABLE,
278-
ComponentManager.Capability.FOCUSABLE,
279295
],
280296
});
281297
this.initialized = true;
@@ -287,6 +303,9 @@ export class Trashcan
287303
* Unlink from all DOM elements to prevent memory leaks.
288304
*/
289305
dispose() {
306+
if (getFocusManager().isRegistered(this)) {
307+
getFocusManager().unregisterTree(this);
308+
}
290309
this.workspace.getComponentManager().removeComponent('trashcan');
291310
if (this.svgGroup) {
292311
dom.removeNode(this.svgGroup);
@@ -662,7 +681,7 @@ export class Trashcan
662681
}
663682

664683
getFocusableTree() {
665-
return this.workspace;
684+
return this;
666685
}
667686

668687
onNodeFocus() {}
@@ -672,6 +691,37 @@ export class Trashcan
672691
return !!this.svgGroup;
673692
}
674693

694+
/** See IFocusableTree.getRootFocusableNode. */
695+
getRootFocusableNode(): IFocusableNode {
696+
return this;
697+
}
698+
699+
/** See IFocusableTree.getRestoredFocusableNode. */
700+
getRestoredFocusableNode(): IFocusableNode | null {
701+
return this;
702+
}
703+
704+
/** See IFocusableTree.getNestedTrees. */
705+
getNestedTrees(): IFocusableTree[] {
706+
return [];
707+
}
708+
709+
/** See IFocusableTree.lookUpFocusableNode. */
710+
lookUpFocusableNode(): IFocusableNode | null {
711+
return null;
712+
}
713+
714+
/** See IFocusableTree.onTreeFocus. */
715+
onTreeFocus(): void {}
716+
717+
/** See IFocusableTree.onTreeBlur. */
718+
onTreeBlur(): void {}
719+
720+
/** See IFocusableTree.getNavigator. */
721+
getNavigator(): WorkspaceControlNavigator {
722+
return this.navigator;
723+
}
724+
675725
performAction() {
676726
this.click();
677727
}

packages/blockly/core/workspace_svg.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,7 @@ export class WorkspaceSvg
953953
}
954954
if (this.zoomControls_) {
955955
this.zoomControls_.dispose();
956+
this.zoomControls_ = null;
956957
}
957958

958959
if (this.audioManager) {
@@ -1016,7 +1017,7 @@ export class WorkspaceSvg
10161017
addTrashcan() {
10171018
this.trashcan = WorkspaceSvg.newTrashcan(this);
10181019
const svgTrashcan = this.trashcan.createDom();
1019-
this.svgGroup_.insertBefore(svgTrashcan, this.getCanvas());
1020+
this.svgGroup_.appendChild(svgTrashcan);
10201021
}
10211022

10221023
/**
@@ -2875,18 +2876,25 @@ export class WorkspaceSvg
28752876

28762877
/** See IFocusableTree.getNestedTrees. */
28772878
getNestedTrees(): Array<IFocusableTree> {
2878-
const nestedWorkspaces = common
2879+
const nestedTrees: IFocusableTree[] = common
28792880
.getAllWorkspaces()
28802881
.filter(
28812882
(w) => w.isMutator && w.options.parentWorkspace === this,
28822883
) as WorkspaceSvg[];
28832884

28842885
const ownFlyout = this.getFlyout(true);
28852886
if (ownFlyout) {
2886-
nestedWorkspaces.push(ownFlyout.getWorkspace());
2887+
nestedTrees.push(ownFlyout.getWorkspace());
28872888
}
28882889

2889-
return nestedWorkspaces;
2890+
if (this.trashcan) {
2891+
nestedTrees.push(this.trashcan);
2892+
}
2893+
if (this.zoomControls_) {
2894+
nestedTrees.push(...this.zoomControls_.getFocusableControls());
2895+
}
2896+
2897+
return nestedTrees;
28902898
}
28912899

28922900
/**

packages/blockly/core/zoom_controls.ts

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import {ComponentManager} from './component_manager.js';
1616
import * as Css from './css.js';
1717
import {EventType} from './events/type.js';
1818
import * as eventUtils from './events/utils.js';
19-
import type {IComponent} from './interfaces/i_component.js';
20-
import {IFocusableNode} from './interfaces/i_focusable_node.js';
19+
import {getFocusManager} from './focus_manager.js';
20+
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
21+
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
2122
import type {IPositionable} from './interfaces/i_positionable.js';
23+
import {WorkspaceControlNavigator} from './keyboard_nav/navigators/workspace_control_navigator.js';
2224
import type {UiMetrics} from './metrics_manager.js';
2325
import {Msg} from './msg.js';
2426
import * as uiPosition from './positionable_helpers.js';
@@ -37,9 +39,10 @@ import type {WorkspaceSvg} from './workspace_svg.js';
3739
*
3840
* @internal
3941
*/
40-
abstract class ZoomControl implements IFocusableNode, IComponent {
42+
abstract class ZoomControl implements IFocusableNode, IFocusableTree {
4143
private pointerDownHandler: browserEvents.Data;
4244
id: string;
45+
private readonly navigator = new WorkspaceControlNavigator();
4346

4447
constructor(
4548
protected workspace: WorkspaceSvg,
@@ -56,6 +59,7 @@ abstract class ZoomControl implements IFocusableNode, IComponent {
5659

5760
this.id = getNextUniqueId();
5861
this.group.id = this.id;
62+
getFocusManager().registerTree(this, false);
5963
}
6064

6165
/**
@@ -90,7 +94,7 @@ abstract class ZoomControl implements IFocusableNode, IComponent {
9094
}
9195

9296
getFocusableTree() {
93-
return this.workspace;
97+
return this;
9498
}
9599

96100
onNodeFocus() {}
@@ -101,9 +105,43 @@ abstract class ZoomControl implements IFocusableNode, IComponent {
101105
return true;
102106
}
103107

104-
abstract performAction(_e: Event): void;
108+
/** See IFocusableTree.getRootFocusableNode. */
109+
getRootFocusableNode(): IFocusableNode {
110+
return this;
111+
}
112+
113+
/** See IFocusableTree.getRestoredFocusableNode. */
114+
getRestoredFocusableNode(): IFocusableNode | null {
115+
return this;
116+
}
117+
118+
/** See IFocusableTree.getNestedTrees. */
119+
getNestedTrees(): IFocusableTree[] {
120+
return [];
121+
}
122+
123+
/** See IFocusableTree.lookUpFocusableNode. */
124+
lookUpFocusableNode(): IFocusableNode | null {
125+
return null;
126+
}
127+
128+
/** See IFocusableTree.onTreeFocus. */
129+
onTreeFocus(): void {}
130+
131+
/** See IFocusableTree.onTreeBlur. */
132+
onTreeBlur(): void {}
133+
134+
/** See IFocusableTree.getNavigator. */
135+
getNavigator(): WorkspaceControlNavigator {
136+
return this.navigator;
137+
}
138+
139+
abstract performAction(e: Event): void;
105140

106141
dispose() {
142+
if (getFocusManager().isRegistered(this)) {
143+
getFocusManager().unregisterTree(this);
144+
}
107145
browserEvents.unbind(this.pointerDownHandler);
108146
}
109147
}
@@ -380,23 +418,26 @@ export class ZoomControls implements IPositionable {
380418
);
381419
}
382420

383-
for (const control of [
384-
this.zoomOutControl,
385-
this.zoomInControl,
386-
this.zoomResetControl,
387-
]) {
388-
if (!control) continue;
389-
390-
this.workspace.getComponentManager().addComponent({
391-
component: control,
392-
weight: ComponentManager.ComponentWeight.ZOOM_CONTROLS_WEIGHT,
393-
capabilities: [ComponentManager.Capability.FOCUSABLE],
394-
});
395-
}
396-
397421
return this.svgGroup;
398422
}
399423

424+
/**
425+
* Returns the individual zoom buttons as focus trees for nesting under the
426+
* workspace.
427+
*
428+
* @internal
429+
*/
430+
getFocusableControls(): IFocusableTree[] {
431+
const controls: IFocusableTree[] = [
432+
this.zoomOutControl!,
433+
this.zoomInControl!,
434+
];
435+
if (this.zoomResetControl) {
436+
controls.push(this.zoomResetControl);
437+
}
438+
return controls;
439+
}
440+
400441
/** Initializes the zoom controls. */
401442
init() {
402443
this.workspace.getComponentManager().addComponent({
@@ -413,12 +454,12 @@ export class ZoomControls implements IPositionable {
413454
*/
414455
dispose() {
415456
this.workspace.getComponentManager().removeComponent('zoomControls');
416-
if (this.svgGroup) {
417-
dom.removeNode(this.svgGroup);
418-
}
419457
this.zoomInControl?.dispose();
420458
this.zoomOutControl?.dispose();
421459
this.zoomResetControl?.dispose();
460+
if (this.svgGroup) {
461+
dom.removeNode(this.svgGroup);
462+
}
422463
}
423464

424465
/**

0 commit comments

Comments
 (0)