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
2 changes: 1 addition & 1 deletion src/line_cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ export class LineCursor extends Marker {
// If there's a block currently selected, remove the selection since the
// cursor should now be hidden.
const curNode = this.getCurNode();
if (curNode.getType() === ASTNode.types.BLOCK) {
if (curNode && curNode.getType() === ASTNode.types.BLOCK) {
const block = curNode.getLocation() as Blockly.BlockSvg;
if (!block.isShadow()) {
Blockly.common.setSelected(null);
Expand Down
26 changes: 25 additions & 1 deletion src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ export class Navigation {
*/
focusWorkspace(
workspace: Blockly.WorkspaceSvg,
keepCursorPosition: boolean = false,
keepCursorPosition = false,
) {
workspace.hideChaff();
const reset = !!workspace.getToolbox();
Expand All @@ -479,6 +479,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
66 changes: 55 additions & 11 deletions src/navigation_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import './gesture_monkey_patch';
import './toolbox_monkey_patch';

import * as Blockly from 'blockly/core';
import {
Expand All @@ -36,6 +37,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 Down Expand Up @@ -65,7 +76,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 @@ -156,18 +167,40 @@ 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.
*
* If the workspace doesn't have a toolbox, this function is a no-op.
*
* @param workspace the workspace that now has input focus.
* @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;

// Hide cursor to indicate lost focus. Also, mark the current node so that
// it can be properly restored upon returning to the workspace.
this.navigation.markAtCursor(workspace);
Expand All @@ -179,15 +212,26 @@ export class NavigationController {
* 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
12 changes: 12 additions & 0 deletions src/toolbox_monkey_patch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import * as Blockly from 'blockly/core';

Blockly.Toolbox.prototype.onKeyDown_ = function () {
// Do nothing since keyboard functionality should be entirely handled by the
// keyboard navigation plugin.
};
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',
},
],
},
],
};