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
16 changes: 14 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,26 @@ 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);

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

// Temporary workaround for #136.
// TODO(#136): fix in core.
workspace.getParentSvg().addEventListener('focus', this.focusListener);
Expand Down
24 changes: 22 additions & 2 deletions src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ export class Navigation {
*/
focusWorkspace(
workspace: Blockly.WorkspaceSvg,
keepCursorPosition: boolean = false,
keepCursorPosition = false,
) {
workspace.hideChaff();
const reset = !!workspace.getToolbox();
Expand All @@ -537,14 +537,34 @@ export class Navigation {
this.setCursorOnWorkspaceFocus(workspace, keepCursorPosition);
}

/**
* Blurs (de-focuses) the workspace's toolbox or flyout, and hides the flyout
* if it's currently visible.
*
* @param workspace The workspace containing the toolbox or flyout.
*/
blurToolboxAndFlyout(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:
* - Resume editing by putting the cursor at the marker location, if any.
* - Resume editing by returning the cursor to its previous location, if any.
* - Move the cursor to the top connection point on on the first top block.
* - Move the cursor to the default location on the workspace.
*
*
* @param workspace The main Blockly workspace.
* @param keepPosition Whether to retain the cursor's previous position.
*/
Expand Down
67 changes: 55 additions & 12 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 Down Expand Up @@ -44,6 +45,16 @@ interface Scope {
connection?: Connection;
}

/** 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 either the toolbox or a flyout has focus. */
TOOLBOX_OR_FLYOUT = 'toolbox_or_flyout',
/** Indicates that the main workspace currently has focus. */
WORKSPACE = 'workspace',
}

/**
* Class for registering shortcuts for keyboard navigation.
*/
Expand All @@ -57,7 +68,7 @@ export class NavigationController {
announcer: Announcer = new Announcer();
shortcutDialog: ShortcutDialog = new ShortcutDialog();

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

/**
* Original Toolbox.prototype.onShortcut method, saved by
Expand Down Expand Up @@ -148,33 +159,65 @@ 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 or flyout focus. This
* will enable keyboard navigation in the toolbox or flyout.
*
* @param workspace the workspace that now has input focus.
* @param workspace the workspace that now has toolbox/flyout input focus.
* @param isFocused whether the environment has browser focus.
*/
setHasFocus(workspace: WorkspaceSvg, isFocused: boolean) {
this.hasNavigationFocus = isFocused;
updateToolboxOrFlyoutFocus(workspace: WorkspaceSvg, isFocused: boolean) {
if (isFocused) {
this.navigation.focusWorkspace(workspace, true);
if (!workspace.getToolbox()) {
this.navigation.focusFlyout(workspace);
} else {
this.navigation.focusToolbox(workspace);
}
this.navigationFocus = NAVIGATION_FOCUS_MODE.TOOLBOX_OR_FLYOUT;
} else {
this.navigation.blurToolboxAndFlyout(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/flyout 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.canCurrentlyNavigateInToolboxOrFlyout(workspace) ||
this.canCurrentlyNavigateInWorkspace(workspace);
}

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

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.
};