Skip to content

Commit 6327ca7

Browse files
authored
feat: Make toolbox & flyout properly focusable. (#225)
* feat: Make toolbox & flyout properly focusable. The toolbox already had a tab stop, but it wouldn't become properly enabled for keyboard navigation. Now it is properly enabled and works when focused both via tab navigation and with the 'T' shortcut. * chore: address reviewer comments * feat: add subcategory to demo toolbox categories This helps to verify subcategory behavior (which has been observed as not completely working currently). * Address reviewer comment for monkey patch install. * Revert monkeypatch installation changes. * Add fix for null current node. It seems possible now to call hide() earlier than previously expected (i.e. when there's a cursor but no current node, such as in the case of focusing the toolbox before the workspace as this PR now makes possible).
1 parent b7f4f29 commit 6327ca7

File tree

7 files changed

+157
-15
lines changed

7 files changed

+157
-15
lines changed

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* The different parts of Blockly that the user navigates between.
1515
*/
1616
export enum STATE {
17+
NOWHERE = 'nowhere',
1718
WORKSPACE = 'workspace',
1819
FLYOUT = 'flyout',
1920
TOOLBOX = 'toolbox',

src/index.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export class KeyboardNavigation {
2929
/** Event handler run when the workspace loses focus. */
3030
private blurListener: () => void;
3131

32+
/** Event handler run when the toolbox gains focus. */
33+
private toolboxFocusListener: () => void;
34+
35+
/** Event handler run when the toolbox loses focus. */
36+
private toolboxBlurListener: () => void;
37+
3238
/** Keyboard navigation controller instance for the workspace. */
3339
private navigationController: NavigationController;
3440

@@ -82,14 +88,29 @@ export class KeyboardNavigation {
8288
workspace.getParentSvg().setAttribute('tabindex', '-1');
8389

8490
this.focusListener = () => {
85-
this.navigationController.setHasFocus(workspace, true);
91+
this.navigationController.updateWorkspaceFocus(workspace, true);
8692
};
8793
this.blurListener = () => {
88-
this.navigationController.setHasFocus(workspace, false);
94+
this.navigationController.updateWorkspaceFocus(workspace, false);
8995
};
9096

9197
workspace.getSvgGroup().addEventListener('focus', this.focusListener);
9298
workspace.getSvgGroup().addEventListener('blur', this.blurListener);
99+
100+
this.toolboxFocusListener = () => {
101+
this.navigationController.updateToolboxFocus(workspace, true);
102+
};
103+
this.toolboxBlurListener = () => {
104+
this.navigationController.updateToolboxFocus(workspace, false);
105+
};
106+
107+
const toolbox = workspace.getToolbox();
108+
if (toolbox != null && toolbox instanceof Blockly.Toolbox) {
109+
const contentsDiv = toolbox.HtmlDiv?.querySelector('.blocklyToolboxContents');
110+
contentsDiv?.addEventListener('focus', this.toolboxFocusListener);
111+
contentsDiv?.addEventListener('blur', this.toolboxBlurListener);
112+
}
113+
93114
// Temporary workaround for #136.
94115
// TODO(#136): fix in core.
95116
workspace.getParentSvg().addEventListener('focus', this.focusListener);
@@ -114,6 +135,13 @@ export class KeyboardNavigation {
114135
.getSvgGroup()
115136
.removeEventListener('focus', this.focusListener);
116137

138+
const toolbox = this.workspace.getToolbox();
139+
if (toolbox != null && toolbox instanceof Blockly.Toolbox) {
140+
const contentsDiv = toolbox.HtmlDiv?.querySelector('.blocklyToolboxContents');
141+
contentsDiv?.removeEventListener('focus', this.toolboxFocusListener);
142+
contentsDiv?.removeEventListener('blur', this.toolboxBlurListener);
143+
}
144+
117145
if (this.workspaceParentTabIndex) {
118146
this.workspace
119147
.getParentSvg()

src/line_cursor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ export class LineCursor extends Marker {
658658
// If there's a block currently selected, remove the selection since the
659659
// cursor should now be hidden.
660660
const curNode = this.getCurNode();
661-
if (curNode.getType() === ASTNode.types.BLOCK) {
661+
if (curNode && curNode.getType() === ASTNode.types.BLOCK) {
662662
const block = curNode.getLocation() as Blockly.BlockSvg;
663663
if (!block.isShadow()) {
664664
Blockly.common.setSelected(null);

src/navigation.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ export class Navigation {
469469
*/
470470
focusWorkspace(
471471
workspace: Blockly.WorkspaceSvg,
472-
keepCursorPosition: boolean = false,
472+
keepCursorPosition = false,
473473
) {
474474
workspace.hideChaff();
475475
const reset = !!workspace.getToolbox();
@@ -479,6 +479,30 @@ export class Navigation {
479479
this.setCursorOnWorkspaceFocus(workspace, keepCursorPosition);
480480
}
481481

482+
/**
483+
* Blurs (de-focuses) the workspace's toolbox, and hides the flyout if it's
484+
* currently visible.
485+
*
486+
* Note that it's up to callers to ensure that this function is only called
487+
* when appropriate (i.e. when the workspace actually has a toolbox that's
488+
* currently focused).
489+
*
490+
* @param workspace The workspace containing the toolbox.
491+
*/
492+
blurToolbox(workspace: Blockly.WorkspaceSvg) {
493+
workspace.hideChaff();
494+
const reset = !!workspace.getToolbox();
495+
496+
this.resetFlyout(workspace, reset);
497+
switch (this.getState(workspace)) {
498+
case Constants.STATE.FLYOUT:
499+
case Constants.STATE.TOOLBOX:
500+
// Clear state since neither the flyout nor toolbox are focused anymore.
501+
this.setState(workspace, Constants.STATE.NOWHERE);
502+
break;
503+
}
504+
}
505+
482506
/**
483507
* Sets the cursor location when focusing the workspace.
484508
* Tries the following, in order, stopping after the first success:

src/navigation_controller.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import './gesture_monkey_patch';
14+
import './toolbox_monkey_patch';
1415

1516
import * as Blockly from 'blockly/core';
1617
import {
@@ -36,6 +37,16 @@ const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind(
3637
ShortcutRegistry.registry,
3738
);
3839

40+
/** Represents the current focus mode of the navigation controller. */
41+
enum NAVIGATION_FOCUS_MODE {
42+
/** Indicates that no interactive elements of Blockly currently have focus. */
43+
NONE = 'none',
44+
/** Indicates that the toolbox currently has focus. */
45+
TOOLBOX = 'toolbox',
46+
/** Indicates that the main workspace currently has focus. */
47+
WORKSPACE = 'workspace',
48+
}
49+
3950
/**
4051
* Class for registering shortcuts for keyboard navigation.
4152
*/
@@ -65,7 +76,7 @@ export class NavigationController {
6576
this.canCurrentlyEdit.bind(this),
6677
);
6778

68-
hasNavigationFocus: boolean = false;
79+
navigationFocus: NAVIGATION_FOCUS_MODE = NAVIGATION_FOCUS_MODE.NONE;
6980

7081
/**
7182
* Original Toolbox.prototype.onShortcut method, saved by
@@ -156,18 +167,40 @@ export class NavigationController {
156167
}
157168

158169
/**
159-
* Sets whether the navigation controller has focus. This will enable keyboard
160-
* navigation if focus is now gained. Additionally, the cursor may be reset if
161-
* it hasn't already been positioned in the workspace.
170+
* Sets whether the navigation controller has toolbox focus and will enable
171+
* keyboard navigation in the toolbox.
172+
*
173+
* If the workspace doesn't have a toolbox, this function is a no-op.
162174
*
163-
* @param workspace the workspace that now has input focus.
175+
* @param workspace the workspace that now has toolbox input focus.
164176
* @param isFocused whether the environment has browser focus.
165177
*/
166-
setHasFocus(workspace: WorkspaceSvg, isFocused: boolean) {
167-
this.hasNavigationFocus = isFocused;
178+
updateToolboxFocus(workspace: WorkspaceSvg, isFocused: boolean) {
179+
if (!workspace.getToolbox()) return;
180+
if (isFocused) {
181+
this.navigation.focusToolbox(workspace);
182+
this.navigationFocus = NAVIGATION_FOCUS_MODE.TOOLBOX;
183+
} else {
184+
this.navigation.blurToolbox(workspace);
185+
this.navigationFocus = NAVIGATION_FOCUS_MODE.NONE;
186+
}
187+
}
188+
189+
/**
190+
* Sets whether the navigation controller has workspace focus. This will
191+
* enable keyboard navigation within the workspace. Additionally, the cursor
192+
* may be reset if it hasn't already been positioned in the workspace.
193+
*
194+
* @param workspace the workspace that now has workspace input focus.
195+
* @param isFocused whether the environment has browser focus.
196+
*/
197+
updateWorkspaceFocus(workspace: WorkspaceSvg, isFocused: boolean) {
168198
if (isFocused) {
169199
this.navigation.focusWorkspace(workspace, true);
200+
this.navigationFocus = NAVIGATION_FOCUS_MODE.WORKSPACE;
170201
} else {
202+
this.navigationFocus = NAVIGATION_FOCUS_MODE.NONE;
203+
171204
// Hide cursor to indicate lost focus. Also, mark the current node so that
172205
// it can be properly restored upon returning to the workspace.
173206
this.navigation.markAtCursor(workspace);
@@ -179,15 +212,26 @@ export class NavigationController {
179212
* Determines whether keyboard navigation should be allowed based on the
180213
* current state of the workspace.
181214
*
182-
* A return value of 'true' generally indicates that the workspace both has
183-
* enabled keyboard navigation and is currently in a state (e.g. focus) that
184-
* can support keyboard navigation.
215+
* A return value of 'true' generally indicates that either the workspace or
216+
* toolbox both has enabled keyboard navigation and is currently in a state
217+
* (e.g. focus) that can support keyboard navigation.
185218
*
186219
* @param workspace the workspace in which keyboard navigation may be allowed.
187220
* @returns whether keyboard navigation is currently allowed.
188221
*/
189222
private canCurrentlyNavigate(workspace: WorkspaceSvg) {
190-
return workspace.keyboardAccessibilityMode && this.hasNavigationFocus;
223+
return this.canCurrentlyNavigateInToolbox(workspace) ||
224+
this.canCurrentlyNavigateInWorkspace(workspace);
225+
}
226+
227+
private canCurrentlyNavigateInToolbox(workspace: WorkspaceSvg) {
228+
return workspace.keyboardAccessibilityMode &&
229+
this.navigationFocus == NAVIGATION_FOCUS_MODE.TOOLBOX;
230+
}
231+
232+
private canCurrentlyNavigateInWorkspace(workspace: WorkspaceSvg) {
233+
return workspace.keyboardAccessibilityMode &&
234+
this.navigationFocus == NAVIGATION_FOCUS_MODE.WORKSPACE;
191235
}
192236

193237
/**

src/toolbox_monkey_patch.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as Blockly from 'blockly/core';
8+
9+
Blockly.Toolbox.prototype.onKeyDown_ = function () {
10+
// Do nothing since keyboard functionality should be entirely handled by the
11+
// keyboard navigation plugin.
12+
};

test/toolboxCategories.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,5 +794,38 @@ export default {
794794
contents: p5CategoryContents,
795795
categorystyle: 'logic_category',
796796
},
797+
{
798+
'kind': 'category',
799+
'name': 'Misc',
800+
'contents': [
801+
{
802+
kind: 'label',
803+
text: 'This is a label',
804+
},
805+
{
806+
'kind': 'category',
807+
'name': 'A subcategory',
808+
'contents': [
809+
{
810+
kind: 'label',
811+
text: 'This is another label',
812+
},
813+
{
814+
kind: 'block',
815+
type: 'colour_random',
816+
},
817+
],
818+
},
819+
{
820+
'kind': 'button',
821+
'text': 'This is a button',
822+
'callbackKey': 'unimplemented',
823+
},
824+
{
825+
kind: 'block',
826+
type: 'colour_random',
827+
},
828+
],
829+
},
797830
],
798831
};

0 commit comments

Comments
 (0)