Skip to content

Commit cb0d1c9

Browse files
authored
fix: Fix navigation between toolbox and flyout in all layouts (#9681)
* fix: Fix navigation between toolbox and flyout in all layouts * test: Add tests
1 parent b665711 commit cb0d1c9

5 files changed

Lines changed: 503 additions & 21 deletions

File tree

packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
import {IFocusableNode} from '../../blockly.js';
88
import type {IFlyout} from '../../interfaces/i_flyout.js';
9+
import {Position} from '../../utils/toolbox.js';
910
import {FlyoutButtonNavigationPolicy} from '../navigation_policies/flyout_button_navigation_policy.js';
1011
import {FlyoutSeparatorNavigationPolicy} from '../navigation_policies/flyout_separator_navigation_policy.js';
1112
import {Navigator} from './navigator.js';
13+
import {getPhysicalToolboxPosition} from './toolbox_navigator.js';
1214

1315
/**
1416
* Navigator that handles keyboard navigation within a flyout.
@@ -23,15 +25,114 @@ export class FlyoutNavigator extends Navigator {
2325
}
2426

2527
/**
26-
* Returns the toolbox when navigating to the left in a flyout.
28+
* Returns the parent toolbox item or previous flyout item when navigating out
29+
* (left arrow) from a flyout.
30+
*
31+
* @param node The flyout item to navigate relative to.
32+
* @param bypassAdjustments True to skip adjusting navigation based on the
33+
* flyout's layout; false (default) to take it into account.
2734
*/
28-
override getOutNode(): IFocusableNode | null {
35+
override getOutNode(
36+
node?: IFocusableNode | null,
37+
bypassAdjustments = false,
38+
): IFocusableNode | null {
39+
if (!bypassAdjustments && this.flyout.targetWorkspace) {
40+
const position = getPhysicalToolboxPosition(this.flyout.targetWorkspace);
41+
switch (position) {
42+
case Position.TOP:
43+
case Position.BOTTOM:
44+
return this.flyout.RTL
45+
? this.getNextNode(node, true)
46+
: this.getPreviousNode(node, true);
47+
case Position.RIGHT:
48+
return null;
49+
}
50+
}
51+
2952
const toolbox = this.flyout.targetWorkspace?.getToolbox();
3053
if (toolbox) return toolbox.getSelectedItem();
3154

3255
return null;
3356
}
3457

58+
/**
59+
* Returns the parent toolbox item or next flyout item when navigating in
60+
* (right arrow) from a flyout.
61+
*
62+
* @param node The flyout item to navigate relative to.
63+
* @param bypassAdjustments True to skip adjusting navigation based on the
64+
* flyout's layout; false (default) to take it into account.
65+
*/
66+
override getInNode(
67+
node?: IFocusableNode | null,
68+
bypassAdjustments = false,
69+
): IFocusableNode | null {
70+
if (!bypassAdjustments && this.flyout.targetWorkspace) {
71+
const position = getPhysicalToolboxPosition(this.flyout.targetWorkspace);
72+
switch (position) {
73+
case Position.TOP:
74+
case Position.BOTTOM:
75+
return this.flyout.RTL
76+
? this.getPreviousNode(node, true)
77+
: this.getNextNode(node, true);
78+
case Position.RIGHT:
79+
return this.getOutNode(node, true);
80+
}
81+
}
82+
83+
return super.getInNode(node);
84+
}
85+
86+
/**
87+
* Returns the parent toolbox item or next flyout item when navigating next
88+
* (down arrow) from a flyout.
89+
*
90+
* @param node The flyout item to navigate relative to.
91+
* @param bypassAdjustments True to skip adjusting navigation based on the
92+
* flyout's layout; false (default) to take it into account.
93+
*/
94+
override getNextNode(
95+
node?: IFocusableNode | null,
96+
bypassAdjustments = false,
97+
): IFocusableNode | null {
98+
if (!bypassAdjustments && this.flyout.targetWorkspace) {
99+
const position = getPhysicalToolboxPosition(this.flyout.targetWorkspace);
100+
switch (position) {
101+
case Position.TOP:
102+
return null;
103+
case Position.BOTTOM:
104+
return this.getOutNode(node, true);
105+
}
106+
}
107+
108+
return super.getNextNode(node);
109+
}
110+
111+
/**
112+
* Returns the parent toolbox item or previous flyout item when navigating
113+
* previous (up arrow) from a flyout.
114+
*
115+
* @param node The flyout item to navigate relative to.
116+
* @param bypassAdjustments True to skip adjusting navigation based on the
117+
* flyout's layout; false (default) to take it into account.
118+
*/
119+
override getPreviousNode(
120+
node?: IFocusableNode | null,
121+
bypassAdjustments = false,
122+
): IFocusableNode | null {
123+
if (!bypassAdjustments && this.flyout.targetWorkspace) {
124+
const position = getPhysicalToolboxPosition(this.flyout.targetWorkspace);
125+
switch (position) {
126+
case Position.TOP:
127+
return this.getOutNode(node, true);
128+
case Position.BOTTOM:
129+
return null;
130+
}
131+
}
132+
133+
return super.getPreviousNode(node);
134+
}
135+
35136
/**
36137
* Returns a list of top-level navigable flyout items.
37138
*/

packages/blockly/core/keyboard_nav/navigators/navigator.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ export class Navigator {
363363
const root = getFocusManager().getFocusedTree()?.getRootFocusableNode();
364364
if (!root) return null;
365365

366-
return this.getFirstChild(root);
366+
return this.getTopLevelItems(root)[0];
367367
}
368368

369369
/**
@@ -372,12 +372,10 @@ export class Navigator {
372372
* @returns The last navigable node on the workspace, or null.
373373
*/
374374
getLastNode(): IFocusableNode | null {
375-
const first = this.getFirstNode();
376-
const oldLooping = this.getNavigationLoops();
377-
this.setNavigationLoops(true);
378-
const lastNode = this.getPreviousNode(first);
379-
this.setNavigationLoops(oldLooping);
380-
return lastNode;
375+
const root = getFocusManager().getFocusedTree()?.getRootFocusableNode();
376+
if (!root) return null;
377+
378+
return this.getTopLevelItems(root).slice(-1)[0];
381379
}
382380

383381
/**

packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {getFocusManager} from '../../focus_manager.js';
88
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
99
import {isSelectableToolboxItem} from '../../interfaces/i_selectable_toolbox_item.js';
1010
import type {IToolbox} from '../../interfaces/i_toolbox.js';
11+
import {Position} from '../../utils/toolbox.js';
12+
import type {WorkspaceSvg} from '../../workspace_svg.js';
1113
import {ToolboxItemNavigationPolicy} from '../navigation_policies/toolbox_item_navigation_policy.js';
1214
import {Navigator} from './navigator.js';
1315

@@ -21,20 +23,118 @@ export class ToolboxNavigator extends Navigator {
2123
}
2224

2325
/**
24-
* Returns the flyout's first item when navigating to the right in a toolbox
25-
* from a toolbox item that has a flyout.
26+
* Returns the flyout's first item (if any) or next toolbox item when
27+
* navigating in (right arrow) from a toolbox.
28+
*
29+
* @param node The toolbox item to navigate relative to.
30+
* @param bypassAdjustments True to skip adjusting navigation based on the
31+
* toolbox's layout; false (default) to take it into account.
2632
*/
2733
override getInNode(
28-
current = getFocusManager().getFocusedNode(),
34+
node = getFocusManager().getFocusedNode(),
35+
bypassAdjustments = false,
2936
): IFocusableNode | null {
30-
if (isSelectableToolboxItem(current) && !current.getContents().length) {
37+
const position = getPhysicalToolboxPosition(this.toolbox.getWorkspace());
38+
if (!bypassAdjustments) {
39+
switch (position) {
40+
case Position.TOP:
41+
case Position.BOTTOM:
42+
return this.getNextNode(node, true);
43+
case Position.RIGHT:
44+
return this.getOutNode(node, true);
45+
}
46+
}
47+
48+
if (isSelectableToolboxItem(node) && !node.getContents().length) {
3149
return null;
3250
}
3351

34-
return (
35-
this.toolbox.getFlyout()?.getWorkspace().getRestoredFocusableNode(null) ??
36-
null
37-
);
52+
const flyoutNavigator = this.toolbox
53+
.getFlyout()
54+
?.getWorkspace()
55+
.getNavigator();
56+
if (!flyoutNavigator) return null;
57+
58+
return this.toolbox.getWorkspace().RTL &&
59+
(position === Position.TOP || position === Position.BOTTOM)
60+
? flyoutNavigator.getLastNode()
61+
: flyoutNavigator.getFirstNode();
62+
}
63+
64+
/**
65+
* Returns the flyout's first item (if any) or previous toolbox item when
66+
* navigating out (left arrow) from a toolbox.
67+
*
68+
* @param node The toolbox item to navigate relative to.
69+
* @param bypassAdjustments True to skip adjusting navigation based on the
70+
* toolbox's layout; false (default) to take it into account.
71+
*/
72+
override getOutNode(
73+
node?: IFocusableNode | null,
74+
bypassAdjustments = false,
75+
): IFocusableNode | null {
76+
if (!bypassAdjustments) {
77+
const position = getPhysicalToolboxPosition(this.toolbox.getWorkspace());
78+
switch (position) {
79+
case Position.TOP:
80+
case Position.BOTTOM:
81+
return this.getPreviousNode(node, true);
82+
case Position.RIGHT:
83+
return this.getInNode(node, true);
84+
}
85+
}
86+
87+
return super.getOutNode(node);
88+
}
89+
90+
/**
91+
* Returns the flyout's first item (if any) or next toolbox item when
92+
* navigating next (down arrow) from a toolbox.
93+
*
94+
* @param node The toolbox item to navigate relative to.
95+
* @param bypassAdjustments True to skip adjusting navigation based on the
96+
* toolbox's layout; false (default) to take it into account.
97+
*/
98+
override getNextNode(
99+
node?: IFocusableNode | null,
100+
bypassAdjustments = false,
101+
): IFocusableNode | null {
102+
if (!bypassAdjustments) {
103+
const position = getPhysicalToolboxPosition(this.toolbox.getWorkspace());
104+
switch (position) {
105+
case Position.TOP:
106+
return this.getInNode(node, true);
107+
case Position.BOTTOM:
108+
return this.getOutNode(node, true);
109+
}
110+
}
111+
112+
return super.getNextNode(node);
113+
}
114+
115+
/**
116+
* Returns the flyout's first item (if any) or previous toolbox item when
117+
* navigating previous (up arrow) from a toolbox.
118+
*
119+
* @param node The toolbox item to navigate relative to.
120+
* @param bypassAdjustments True to skip adjusting navigation based on the
121+
* toolbox's layout; false (default) to take it into account.
122+
*/
123+
override getPreviousNode(
124+
node?: IFocusableNode | null,
125+
bypassAdjustments = false,
126+
): IFocusableNode | null {
127+
if (!bypassAdjustments) {
128+
const position = getPhysicalToolboxPosition(this.toolbox.getWorkspace());
129+
switch (position) {
130+
case Position.TOP:
131+
return this.getOutNode(node, true);
132+
case Position.BOTTOM:
133+
return this.getInNode(node, true);
134+
}
135+
}
136+
137+
return super.getPreviousNode(node);
38138
}
39139

40140
/**
@@ -44,3 +144,26 @@ export class ToolboxNavigator extends Navigator {
44144
return this.toolbox.getToolboxItems();
45145
}
46146
}
147+
148+
/**
149+
* Although developers specify the toolbox position as "start" or "end", this
150+
* gets normalized by the injection options parser based on RTL, such that "end"
151+
* in RTL means the left. When dealing with arrow keys, we want the actual/
152+
* physical position on screen, not the logical position. This function converts
153+
* the stored logical position to the physical position.
154+
*
155+
* @internal
156+
* @param workspace The workspace to use injection options from.
157+
* @returns The physical location of the toolbox/flyout on screen.
158+
*/
159+
export function getPhysicalToolboxPosition(workspace: WorkspaceSvg): Position {
160+
const logicalPosition = workspace.options.toolboxPosition;
161+
if (
162+
workspace.options.RTL &&
163+
!(logicalPosition === Position.TOP || logicalPosition === Position.BOTTOM)
164+
) {
165+
return logicalPosition === Position.LEFT ? Position.RIGHT : Position.LEFT;
166+
}
167+
168+
return logicalPosition;
169+
}

packages/blockly/core/shortcut_items.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,8 @@ export function registerArrowNavigation() {
570570
right: {
571571
name: names.NAVIGATE_RIGHT,
572572
preconditionFn: (workspace) => !workspace.isDragging(),
573-
callback: (workspace) => {
573+
callback: (workspace, e) => {
574+
e.preventDefault();
574575
keyboardNavigationController.setIsActive(true);
575576
const node = workspace.RTL
576577
? getFocusManager().getFocusedTree()?.getNavigator().getOutNode()
@@ -587,7 +588,8 @@ export function registerArrowNavigation() {
587588
left: {
588589
name: names.NAVIGATE_LEFT,
589590
preconditionFn: (workspace) => !workspace.isDragging(),
590-
callback: (workspace) => {
591+
callback: (workspace, e) => {
592+
e.preventDefault();
591593
keyboardNavigationController.setIsActive(true);
592594
const node = workspace.RTL
593595
? getFocusManager().getFocusedTree()?.getNavigator().getInNode()
@@ -604,7 +606,8 @@ export function registerArrowNavigation() {
604606
down: {
605607
name: names.NAVIGATE_DOWN,
606608
preconditionFn: (workspace) => !workspace.isDragging(),
607-
callback: () => {
609+
callback: (_workspace, e) => {
610+
e.preventDefault();
608611
keyboardNavigationController.setIsActive(true);
609612
const node = getFocusManager()
610613
.getFocusedTree()
@@ -621,7 +624,8 @@ export function registerArrowNavigation() {
621624
up: {
622625
name: names.NAVIGATE_UP,
623626
preconditionFn: (workspace) => !workspace.isDragging(),
624-
callback: () => {
627+
callback: (_workspace, e) => {
628+
e.preventDefault();
625629
keyboardNavigationController.setIsActive(true);
626630
const node = getFocusManager()
627631
.getFocusedTree()

0 commit comments

Comments
 (0)