diff --git a/src/constants.ts b/src/constants.ts index dec951c6..e077eff2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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', diff --git a/src/index.ts b/src/index.ts index 6f8c0e49..ca90fc3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; @@ -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); @@ -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() diff --git a/src/line_cursor.ts b/src/line_cursor.ts index 2ff67811..d5558a5e 100644 --- a/src/line_cursor.ts +++ b/src/line_cursor.ts @@ -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); diff --git a/src/navigation.ts b/src/navigation.ts index 21fff1b3..f7ff6ce7 100644 --- a/src/navigation.ts +++ b/src/navigation.ts @@ -469,7 +469,7 @@ export class Navigation { */ focusWorkspace( workspace: Blockly.WorkspaceSvg, - keepCursorPosition: boolean = false, + keepCursorPosition = false, ) { workspace.hideChaff(); const reset = !!workspace.getToolbox(); @@ -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: diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index 695fd82f..c653a2df 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -11,6 +11,7 @@ */ import './gesture_monkey_patch'; +import './toolbox_monkey_patch'; import * as Blockly from 'blockly/core'; import { @@ -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. */ @@ -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 @@ -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); @@ -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; } /** diff --git a/src/toolbox_monkey_patch.js b/src/toolbox_monkey_patch.js new file mode 100644 index 00000000..2649da51 --- /dev/null +++ b/src/toolbox_monkey_patch.js @@ -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. +}; diff --git a/test/toolboxCategories.js b/test/toolboxCategories.js index 9969a497..fe7eb8f7 100644 --- a/test/toolboxCategories.js +++ b/test/toolboxCategories.js @@ -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', + }, + ], + }, ], };