Skip to content
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* The different parts of Blockly that the user navigates between.
*/
export enum STATE {
NOWHERE = 'nowhere',
WORKSPACE = 'workspace',
FLYOUT = 'flyout',
TOOLBOX = 'toolbox',
Expand Down
32 changes: 30 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export class KeyboardNavigation {
/** Event handler run when the workspace loses focus. */
private blurListener: () => void;

/** Event handler run when the toolbox gains focus. */
private toolboxFocusListener: () => void;

/** Event handler run when the toolbox loses focus. */
private toolboxBlurListener: () => void;

/** Keyboard navigation controller instance for the workspace. */
private navigationController: NavigationController;

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

this.focusListener = () => {
this.navigationController.setHasFocus(workspace, true);
this.navigationController.updateWorkspaceFocus(workspace, true);
};
this.blurListener = () => {
this.navigationController.setHasFocus(workspace, false);
this.navigationController.updateWorkspaceFocus(workspace, false);
};

workspace.getSvgGroup().addEventListener('focus', this.focusListener);
workspace.getSvgGroup().addEventListener('blur', this.blurListener);

this.toolboxFocusListener = () => {
this.navigationController.updateToolboxFocus(workspace, true);
};
this.toolboxBlurListener = () => {
this.navigationController.updateToolboxFocus(workspace, false);
};

const toolbox = workspace.getToolbox();
if (toolbox != null && toolbox instanceof Blockly.Toolbox) {
const contentsDiv = toolbox.HtmlDiv?.querySelector('.blocklyToolboxContents');
contentsDiv?.addEventListener('focus', this.toolboxFocusListener);
contentsDiv?.addEventListener('blur', this.toolboxBlurListener);
}

// Temporary workaround for #136.
// TODO(#136): fix in core.
workspace.getParentSvg().addEventListener('focus', this.focusListener);
Expand All @@ -114,6 +135,13 @@ export class KeyboardNavigation {
.getSvgGroup()
.removeEventListener('focus', this.focusListener);

const toolbox = this.workspace.getToolbox();
if (toolbox != null && toolbox instanceof Blockly.Toolbox) {
const contentsDiv = toolbox.HtmlDiv?.querySelector('.blocklyToolboxContents');
contentsDiv?.removeEventListener('focus', this.toolboxFocusListener);
contentsDiv?.removeEventListener('blur', this.toolboxBlurListener);
}

if (this.workspaceParentTabIndex) {
this.workspace
.getParentSvg()
Expand Down
26 changes: 25 additions & 1 deletion src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ export class Navigation {
*/
focusWorkspace(
workspace: Blockly.WorkspaceSvg,
keepCursorPosition: boolean = false,
keepCursorPosition = false,
) {
workspace.hideChaff();
const reset = !!workspace.getToolbox();
Expand All @@ -484,6 +484,30 @@ export class Navigation {
this.setCursorOnWorkspaceFocus(workspace, keepCursorPosition);
}

/**
* Blurs (de-focuses) the workspace's toolbox, and hides the flyout if it's
* currently visible.
*
* Note that it's up to callers to ensure that this function is only called
* when appropriate (i.e. when the workspace actually has a toolbox that's
* currently focused).
*
* @param workspace The workspace containing the toolbox.
*/
blurToolbox(workspace: Blockly.WorkspaceSvg) {
workspace.hideChaff();
const reset = !!workspace.getToolbox();

this.resetFlyout(workspace, reset);
switch (this.getState(workspace)) {
case Constants.STATE.FLYOUT:
case Constants.STATE.TOOLBOX:
// Clear state since neither the flyout nor toolbox are focused anymore.
this.setState(workspace, Constants.STATE.NOWHERE);
break;
}
}

/**
* Sets the cursor location when focusing the workspace.
* Tries the following, in order, stopping after the first success:
Expand Down
65 changes: 54 additions & 11 deletions src/navigation_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind(
ShortcutRegistry.registry,
);

/** Represents the current focus mode of the navigation controller. */
enum NAVIGATION_FOCUS_MODE {
/** Indicates that no interactive elements of Blockly currently have focus. */
NONE = 'none',
/** Indicates that the toolbox currently has focus. */
TOOLBOX = 'toolbox',
/** Indicates that the main workspace currently has focus. */
WORKSPACE = 'workspace',
}

/**
* Class for registering shortcuts for keyboard navigation.
*/
Expand All @@ -60,7 +70,7 @@ export class NavigationController {
this.canCurrentlyEdit.bind(this),
);

hasNavigationFocus: boolean = false;
navigationFocus: NAVIGATION_FOCUS_MODE = NAVIGATION_FOCUS_MODE.NONE;

/**
* Original Toolbox.prototype.onShortcut method, saved by
Expand Down Expand Up @@ -151,33 +161,66 @@ export class NavigationController {
}

/**
* Sets whether the navigation controller has focus. This will enable keyboard
* navigation if focus is now gained. Additionally, the cursor may be reset if
* it hasn't already been positioned in the workspace.
* Sets whether the navigation controller has toolbox focus and will enable
* keyboard navigation in the toolbox.
*
* @param workspace the workspace that now has input focus.
* If the workspace doesn't have a toolbox, this function is a no-op.
*
* @param workspace the workspace that now has toolbox input focus.
* @param isFocused whether the environment has browser focus.
*/
setHasFocus(workspace: WorkspaceSvg, isFocused: boolean) {
this.hasNavigationFocus = isFocused;
updateToolboxFocus(workspace: WorkspaceSvg, isFocused: boolean) {
if (!workspace.getToolbox()) return;
if (isFocused) {
this.navigation.focusToolbox(workspace);
this.navigationFocus = NAVIGATION_FOCUS_MODE.TOOLBOX;
} else {
this.navigation.blurToolbox(workspace);
this.navigationFocus = NAVIGATION_FOCUS_MODE.NONE;
}
}

/**
* Sets whether the navigation controller has workspace focus. This will
* enable keyboard navigation within the workspace. Additionally, the cursor
* may be reset if it hasn't already been positioned in the workspace.
*
* @param workspace the workspace that now has workspace input focus.
* @param isFocused whether the environment has browser focus.
*/
updateWorkspaceFocus(workspace: WorkspaceSvg, isFocused: boolean) {
if (isFocused) {
this.navigation.focusWorkspace(workspace, true);
this.navigationFocus = NAVIGATION_FOCUS_MODE.WORKSPACE;
} else {
this.navigationFocus = NAVIGATION_FOCUS_MODE.NONE;
}
}

/**
* Determines whether keyboard navigation should be allowed based on the
* current state of the workspace.
*
* A return value of 'true' generally indicates that the workspace both has
* enabled keyboard navigation and is currently in a state (e.g. focus) that
* can support keyboard navigation.
* A return value of 'true' generally indicates that either the workspace or
* toolbox both has enabled keyboard navigation and is currently in a state
* (e.g. focus) that can support keyboard navigation.
*
* @param workspace the workspace in which keyboard navigation may be allowed.
* @returns whether keyboard navigation is currently allowed.
*/
private canCurrentlyNavigate(workspace: WorkspaceSvg) {
return workspace.keyboardAccessibilityMode && this.hasNavigationFocus;
return this.canCurrentlyNavigateInToolbox(workspace) ||
this.canCurrentlyNavigateInWorkspace(workspace);
}

private canCurrentlyNavigateInToolbox(workspace: WorkspaceSvg) {
return workspace.keyboardAccessibilityMode &&
this.navigationFocus == NAVIGATION_FOCUS_MODE.TOOLBOX;
}

private canCurrentlyNavigateInWorkspace(workspace: WorkspaceSvg) {
return workspace.keyboardAccessibilityMode &&
this.navigationFocus == NAVIGATION_FOCUS_MODE.WORKSPACE;
}

/**
Expand Down
14 changes: 14 additions & 0 deletions src/toolbox_monkey_patch.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Installs a Toolbox-specific monkey-patch. Note that this must be installed
* before any Toolboxes are registered for key bindings.
*/
export declare function install(): void;

/** Uninstalls the Toolbox-specific monkey-patch. */
export declare function uninstall(): void;
25 changes: 25 additions & 0 deletions src/toolbox_monkey_patch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import * as Blockly from 'blockly/core';

let oldOnKeyDownHandler = null;

export function install() {
oldOnKeyDownHandler = Blockly.Toolbox.prototype.onKeyDown_;
Blockly.Toolbox.prototype.onKeyDown_ = function () {
// Do nothing since keyboard functionality should be entirely handled by the
// keyboard navigation plugin.
};
};

export function uninstall() {
if (!oldOnKeyDownHandler) {
throw new Error("Trying to dispose non-inited monkey patch.");
}
Blockly.Toolbox.prototype.onKeyDown_ = oldOnKeyDownHandler;
oldOnKeyDownHandler = null;
};
2 changes: 2 additions & 0 deletions test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {javascriptGenerator} from 'blockly/javascript';
// @ts-expect-error No types in js file
import {load} from './loadTestBlocks';
import {runCode, registerRunCodeShortcut} from './runCode';
import * as ToolboxMonkeyPatch from '../src/toolbox_monkey_patch';

/**
* Parse query params for inject and navigation options and update
Expand Down Expand Up @@ -83,6 +84,7 @@ function createWorkspace(): Blockly.WorkspaceSvg {
renderer,
};
const blocklyDiv = document.getElementById('blocklyDiv')!;
ToolboxMonkeyPatch.install();
const workspace = Blockly.inject(blocklyDiv, injectOptions);

const navigationOptions = {
Expand Down
33 changes: 33 additions & 0 deletions test/toolboxCategories.js
Original file line number Diff line number Diff line change
Expand Up @@ -794,5 +794,38 @@ export default {
contents: p5CategoryContents,
categorystyle: 'logic_category',
},
{
'kind': 'category',
'name': 'Misc',
'contents': [
{
kind: 'label',
text: 'This is a label',
},
{
'kind': 'category',
'name': 'A subcategory',
'contents': [
{
kind: 'label',
text: 'This is another label',
},
{
kind: 'block',
type: 'colour_random',
},
],
},
{
'kind': 'button',
'text': 'This is a button',
'callbackKey': 'unimplemented',
},
{
kind: 'block',
type: 'colour_random',
},
],
},
],
};