diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 0bdb726b8c0..666436c1eac 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -1905,4 +1905,58 @@ export class BlockSvg canBeFocused(): boolean { return true; } + + /** + * Returns a set of all of the parent blocks of the given block. + * + * @internal + * @returns A set of the parents of the given block. + */ + getParents(): Set { + const parents = new Set(); + let parent = this.getParent(); + while (parent) { + parents.add(parent); + parent = parent.getParent(); + } + + return parents; + } + + /** + * Returns a set of all of the parent blocks connected to an output of the + * given block or one of its parents. Also includes the given block. + * + * @internal + * @returns A set of the output-connected parents of the given block. + */ + getOutputParents(): Set { + const parents = new Set(); + parents.add(this); + let parent = this.outputConnection?.targetBlock(); + while (parent) { + parents.add(parent); + parent = parent.outputConnection?.targetBlock(); + } + + return parents; + } + + /** + * Returns an ID for the visual "row" this block is part of. + * + * @internal + */ + getRowId(): string { + const connectedInput = + this.outputConnection?.targetConnection?.getParentInput(); + // Blocks with an output value have the same ID as the input they're + // connected to. + if (connectedInput) { + return connectedInput.getRowId(); + } + + // All other blocks are their own row. + return this.id; + } } diff --git a/packages/blockly/core/blockly.ts b/packages/blockly/core/blockly.ts index 7377ff9098b..3e28908d172 100644 --- a/packages/blockly/core/blockly.ts +++ b/packages/blockly/core/blockly.ts @@ -177,15 +177,13 @@ import { import {IVariableMap} from './interfaces/i_variable_map.js'; import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; import * as internalConstants from './internal_constants.js'; -import {LineCursor} from './keyboard_nav/line_cursor.js'; -import {Marker} from './keyboard_nav/marker.js'; +import {ToolboxNavigator} from './keyboard_nav/navigators/toolbox_navigator.js'; import { KeyboardNavigationController, keyboardNavigationController, } from './keyboard_navigation_controller.js'; import type {LayerManager} from './layer_manager.js'; import * as layers from './layers.js'; -import {MarkerManager} from './marker_manager.js'; import {Menu} from './menu.js'; import {MenuItem} from './menuitem.js'; import {MetricsManager} from './metrics_manager.js'; @@ -439,16 +437,21 @@ Names.prototype.populateProcedures = function ( }; // clang-format on -export * from './flyout_navigator.js'; export * from './interfaces/i_navigation_policy.js'; -export * from './keyboard_nav/block_navigation_policy.js'; -export * from './keyboard_nav/connection_navigation_policy.js'; -export * from './keyboard_nav/field_navigation_policy.js'; -export * from './keyboard_nav/flyout_button_navigation_policy.js'; -export * from './keyboard_nav/flyout_navigation_policy.js'; -export * from './keyboard_nav/flyout_separator_navigation_policy.js'; -export * from './keyboard_nav/workspace_navigation_policy.js'; -export * from './navigator.js'; +export * from './keyboard_nav/navigation_policies/block_comment_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/block_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/comment_editor_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/connection_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/field_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/flyout_button_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/flyout_separator_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/icon_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/toolbox_item_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/workspace_comment_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/workspace_navigation_policy.js'; +export * from './keyboard_nav/navigators/flyout_navigator.js'; +export * from './keyboard_nav/navigators/navigator.js'; export * from './toast.js'; // Re-export submodules that no longer declareLegacyNamespace. @@ -471,7 +474,6 @@ export { DragTarget, Events, Extensions, - LineCursor, Procedures, ShortcutItems, Themes, @@ -596,8 +598,6 @@ export { KeyboardNavigationController, LabelFlyoutInflater, LayerManager, - Marker, - MarkerManager, Menu, MenuGenerator, MenuGeneratorFunction, @@ -619,6 +619,7 @@ export { Toolbox, ToolboxCategory, ToolboxItem, + ToolboxNavigator, ToolboxSeparator, Trashcan, UnattachedFieldError, diff --git a/packages/blockly/core/comments/comment_editor.ts b/packages/blockly/core/comments/comment_editor.ts index 92c92fa5464..5d41a7c3866 100644 --- a/packages/blockly/core/comments/comment_editor.ts +++ b/packages/blockly/core/comments/comment_editor.ts @@ -25,7 +25,7 @@ export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_'; /** The part of a comment that can be typed into. */ export class CommentEditor implements IFocusableNode { - id?: string; + id: string; /** The foreignObject containing the HTML text area. */ private foreignObject: SVGForeignObjectElement; @@ -42,7 +42,7 @@ export class CommentEditor implements IFocusableNode { constructor( public workspace: WorkspaceSvg, - commentId?: string, + commentId: string, private onFinishEditing?: () => void, ) { this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, { @@ -67,10 +67,8 @@ export class CommentEditor implements IFocusableNode { body.appendChild(this.textArea); this.foreignObject.appendChild(body); - if (commentId) { - this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER; - this.textArea.setAttribute('id', this.id); - } + this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER; + this.textArea.setAttribute('id', this.id); // Register browser event listeners for the user typing in the textarea. browserEvents.conditionalBind( diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index a8377ae050f..dca834fb9ee 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -15,6 +15,7 @@ import './events/events_block_change.js'; import {BlockSvg} from './block_svg.js'; +import {IFocusableNode} from './blockly.js'; import * as browserEvents from './browser_events.js'; import * as bumpObjects from './bump_objects.js'; import * as dialog from './dialog.js'; @@ -28,7 +29,6 @@ import { UnattachedFieldError, } from './field.js'; import {getFocusManager} from './focus_manager.js'; -import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; import * as aria from './utils/aria.js'; @@ -600,16 +600,20 @@ export abstract class FieldInput extends Field< dropDownDiv.hideWithoutAnimation(); } else if (e.key === 'Tab') { e.preventDefault(); - const cursor = this.workspace_?.getCursor(); + const navigator = this.workspace_?.getNavigator(); const isValidDestination = (node: IFocusableNode | null) => (node instanceof FieldInput || (node instanceof BlockSvg && node.isSimpleReporter())) && node !== this.getSourceBlock(); - let target = e.shiftKey - ? cursor?.getPreviousNode(this, isValidDestination, false) - : cursor?.getNextNode(this, isValidDestination, false); + // eslint-disable-next-line @typescript-eslint/no-this-alias + let target: IFocusableNode | null | undefined = this; + do { + target = e.shiftKey + ? navigator?.getOutNode(target) + : navigator?.getInNode(target); + } while (target && !isValidDestination(target)); target = target instanceof BlockSvg && target.isSimpleReporter() ? target.getFields().next().value @@ -625,7 +629,9 @@ export abstract class FieldInput extends Field< targetSourceBlock instanceof BlockSvg ) { getFocusManager().focusNode(targetSourceBlock); - } else getFocusManager().focusNode(target); + } else { + getFocusManager().focusNode(target); + } target.showEditor(); } } diff --git a/packages/blockly/core/flyout_base.ts b/packages/blockly/core/flyout_base.ts index d89027ab4ca..fb2ff01c2d7 100644 --- a/packages/blockly/core/flyout_base.ts +++ b/packages/blockly/core/flyout_base.ts @@ -19,13 +19,11 @@ import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import {FlyoutItem} from './flyout_item.js'; import {FlyoutMetricsManager} from './flyout_metrics_manager.js'; -import {FlyoutNavigator} from './flyout_navigator.js'; import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; -import {IFocusableNode} from './interfaces/i_focusable_node.js'; -import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import {FlyoutNavigator} from './keyboard_nav/navigators/flyout_navigator.js'; import type {Options} from './options.js'; import * as registry from './registry.js'; import * as renderManagement from './render_management.js'; @@ -42,7 +40,7 @@ import {WorkspaceSvg} from './workspace_svg.js'; */ export abstract class Flyout extends DeleteArea - implements IAutoHideable, IFlyout, IFocusableNode + implements IAutoHideable, IFlyout { /** * Position the flyout. @@ -797,86 +795,4 @@ export abstract class Flyout return null; } - - /** - * See IFocusableNode.getFocusableElement. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getFocusableElement(): HTMLElement | SVGElement { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.getFocusableTree. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getFocusableTree(): IFocusableTree { - throw new Error('Flyouts are not directly focusable.'); - } - - /** See IFocusableNode.onNodeFocus. */ - onNodeFocus(): void {} - - /** See IFocusableNode.onNodeBlur. */ - onNodeBlur(): void {} - - /** See IFocusableNode.canBeFocused. */ - canBeFocused(): boolean { - return false; - } - - /** - * See IFocusableNode.getRootFocusableNode. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getRootFocusableNode(): IFocusableNode { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.getRestoredFocusableNode. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getRestoredFocusableNode( - _previousNode: IFocusableNode | null, - ): IFocusableNode | null { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.getNestedTrees. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getNestedTrees(): Array { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.lookUpFocusableNode. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - lookUpFocusableNode(_id: string): IFocusableNode | null { - throw new Error('Flyouts are not directly focusable.'); - } - - /** See IFocusableTree.onTreeFocus. */ - onTreeFocus( - _node: IFocusableNode, - _previousTree: IFocusableTree | null, - ): void {} - - /** - * See IFocusableNode.onTreeBlur. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - onTreeBlur(_nextTree: IFocusableTree | null): void { - throw new Error('Flyouts are not directly focusable.'); - } } diff --git a/packages/blockly/core/flyout_navigator.ts b/packages/blockly/core/flyout_navigator.ts deleted file mode 100644 index a102ce81765..00000000000 --- a/packages/blockly/core/flyout_navigator.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {IFlyout} from './interfaces/i_flyout.js'; -import {FlyoutButtonNavigationPolicy} from './keyboard_nav/flyout_button_navigation_policy.js'; -import {FlyoutNavigationPolicy} from './keyboard_nav/flyout_navigation_policy.js'; -import {FlyoutSeparatorNavigationPolicy} from './keyboard_nav/flyout_separator_navigation_policy.js'; -import {Navigator} from './navigator.js'; - -export class FlyoutNavigator extends Navigator { - constructor(flyout: IFlyout) { - super(); - this.rules.push( - new FlyoutButtonNavigationPolicy(), - new FlyoutSeparatorNavigationPolicy(), - ); - this.rules = this.rules.map( - (rule) => new FlyoutNavigationPolicy(rule, flyout), - ); - } -} diff --git a/packages/blockly/core/inputs/input.ts b/packages/blockly/core/inputs/input.ts index 90d9ba7f52b..ee5f7fdc086 100644 --- a/packages/blockly/core/inputs/input.ts +++ b/packages/blockly/core/inputs/input.ts @@ -17,7 +17,7 @@ import '../field_label.js'; import type {Block} from '../block.js'; import type {BlockSvg} from '../block_svg.js'; import type {Connection} from '../connection.js'; -import type {ConnectionType} from '../connection_type.js'; +import {ConnectionType} from '../connection_type.js'; import type {Field} from '../field.js'; import * as fieldRegistry from '../field_registry.js'; import {RenderedConnection} from '../rendered_connection.js'; @@ -314,4 +314,37 @@ export class Input { protected makeConnection(type: ConnectionType): Connection { return this.sourceBlock.makeConnection_(type); } + + /** + * Returns an ID for the visual "row" this input is part of. + * + * @internal + */ + getRowId(): string { + const inputs = this.getSourceBlock().inputList; + + // The first input in a block has the same ID as its parent block. + if (this === inputs[0]) { + return (this.getSourceBlock() as BlockSvg).getRowId(); + } + + const inputIndex = inputs.indexOf(this); + const precedingStatementInput = + inputs[inputIndex - 1].connection?.type === ConnectionType.NEXT_STATEMENT; + + // Each subsequent (a) external input (b) statement input or (c) inline + // input following a statement input is on its own row and has its own row + // ID. + if ( + !this.getSourceBlock().getInputsInline() || + this.connection?.type === ConnectionType.NEXT_STATEMENT || + precedingStatementInput + ) { + return `${this.getSourceBlock().id}-input${inputIndex}`; + } + + // Value inputs on a inline input block have the same row ID as their + // preceding input, since they're all on one row. + return inputs[inputIndex - 1].getRowId(); + } } diff --git a/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts b/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts index 0b591b4a6ff..6e29e584304 100644 --- a/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts +++ b/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts @@ -6,7 +6,10 @@ // Former goog.module ID: Blockly.ICollapsibleToolboxItem -import type {ISelectableToolboxItem} from './i_selectable_toolbox_item.js'; +import { + type ISelectableToolboxItem, + isSelectableToolboxItem, +} from './i_selectable_toolbox_item.js'; import type {IToolboxItem} from './i_toolbox_item.js'; /** @@ -31,3 +34,18 @@ export interface ICollapsibleToolboxItem extends ISelectableToolboxItem { /** Toggles whether or not the toolbox item is expanded. */ toggleExpanded(): void; } + +/** + * Type guard that checks whether an object is an ICollapsibleToolboxItem. + */ +export function isCollapsibleToolboxItem( + obj: any, +): obj is ICollapsibleToolboxItem { + return ( + typeof obj.getChildToolboxItems === 'function' && + typeof obj.isExpanded === 'function' && + typeof obj.toggleExpanded === 'function' && + isSelectableToolboxItem(obj) && + obj.isCollapsible() + ); +} diff --git a/packages/blockly/core/interfaces/i_flyout.ts b/packages/blockly/core/interfaces/i_flyout.ts index 6906d5857b0..e826d17a8bd 100644 --- a/packages/blockly/core/interfaces/i_flyout.ts +++ b/packages/blockly/core/interfaces/i_flyout.ts @@ -10,13 +10,12 @@ import type {FlyoutItem} from '../flyout_item.js'; import type {Svg} from '../utils/svg.js'; import type {FlyoutDefinition} from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; -import {IFocusableTree} from './i_focusable_tree.js'; import type {IRegistrable} from './i_registrable.js'; /** * Interface for a flyout. */ -export interface IFlyout extends IRegistrable, IFocusableTree { +export interface IFlyout extends IRegistrable { /** Whether the flyout is laid out horizontally or not. */ horizontalLayout: boolean; diff --git a/packages/blockly/core/interfaces/i_focusable_tree.ts b/packages/blockly/core/interfaces/i_focusable_tree.ts index c33189fcdf0..d3ed925caf5 100644 --- a/packages/blockly/core/interfaces/i_focusable_tree.ts +++ b/packages/blockly/core/interfaces/i_focusable_tree.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {Navigator} from '../keyboard_nav/navigators/navigator'; import type {IFocusableNode} from './i_focusable_node.js'; /** @@ -122,6 +123,14 @@ export interface IFocusableTree { * as in the case that Blockly is entirely losing DOM focus). */ onTreeBlur(nextTree: IFocusableTree | null): void; + + /** + * Returns a Navigator instance to be used to determine the navigation order + * between IFocusableNodes contained within this IFocusableTree. Generally + * this can just be an instance of Navigator, but trees may choose to return a + * subclass to customize navigation behavior within their context. + */ + getNavigator(): Navigator; } /** diff --git a/packages/blockly/core/interfaces/i_navigation_policy.ts b/packages/blockly/core/interfaces/i_navigation_policy.ts index 8e1ce6c1005..e41ef29d2bc 100644 --- a/packages/blockly/core/interfaces/i_navigation_policy.ts +++ b/packages/blockly/core/interfaces/i_navigation_policy.ts @@ -44,6 +44,19 @@ export interface INavigationPolicy { */ getPreviousSibling(current: T): IFocusableNode | null; + /** + * Returns an ID corresponding to the visual "row" the given element is part + * of. All elements on the same visual row should share the same ID. For + * example, icons share their parent block's row ID, as do inline connected + * blocks or value inputs. Statement inputs, external inputs, or blocks + * connected to one another's previous or next connections form distinct + * visual rows and should have distinct row IDs. + * + * @param current The element to return the row ID of. + * @returns The row ID of the given element. + */ + getRowId(current: T): string; + /** * Returns whether or not the given instance should be reachable via keyboard * navigation. diff --git a/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts b/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts index 890d4e370af..33fe4a7d4a6 100644 --- a/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts +++ b/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts @@ -7,7 +7,7 @@ // Former goog.module ID: Blockly.ISelectableToolboxItem import type {FlyoutItemInfoArray} from '../utils/toolbox'; -import type {IToolboxItem} from './i_toolbox_item.js'; +import {isToolboxItem, type IToolboxItem} from './i_toolbox_item.js'; /** * Interface for an item in the toolbox that can be selected. @@ -54,10 +54,17 @@ export interface ISelectableToolboxItem extends IToolboxItem { } /** - * Type guard that checks whether an IToolboxItem is an ISelectableToolboxItem. + * Type guard that checks whether an object is an ISelectableToolboxItem. */ export function isSelectableToolboxItem( - toolboxItem: IToolboxItem, -): toolboxItem is ISelectableToolboxItem { - return toolboxItem.isSelectable(); + obj: any, +): obj is ISelectableToolboxItem { + return ( + typeof obj.getName === 'function' && + typeof obj.getContents === 'function' && + typeof obj.setSelected === 'function' && + typeof obj.onClick === 'function' && + isToolboxItem(obj) && + obj.isSelectable() + ); } diff --git a/packages/blockly/core/interfaces/i_toolbox.ts b/packages/blockly/core/interfaces/i_toolbox.ts index f5d9c9fd7c6..614f19d9f29 100644 --- a/packages/blockly/core/interfaces/i_toolbox.ts +++ b/packages/blockly/core/interfaces/i_toolbox.ts @@ -118,4 +118,9 @@ export interface IToolbox extends IRegistrable, IFocusableTree { /** Disposes of this toolbox. */ dispose(): void; + + /** + * Returns a list of items in this toolbox. + */ + getToolboxItems(): IToolboxItem[]; } diff --git a/packages/blockly/core/interfaces/i_toolbox_item.ts b/packages/blockly/core/interfaces/i_toolbox_item.ts index 661624fd7e8..25c4dab847a 100644 --- a/packages/blockly/core/interfaces/i_toolbox_item.ts +++ b/packages/blockly/core/interfaces/i_toolbox_item.ts @@ -7,6 +7,7 @@ // Former goog.module ID: Blockly.IToolboxItem import type {IFocusableNode} from './i_focusable_node.js'; +import type {IToolbox} from './i_toolbox.js'; /** * Interface for an item in the toolbox. @@ -80,4 +81,26 @@ export interface IToolboxItem extends IFocusableNode { * @param isVisible True if category should be visible. */ setVisible_(isVisible: boolean): void; + + getParentToolbox(): IToolbox; +} + +/** + * Type guard that checks whether an object is an IToolboxItem. + */ +export function isToolboxItem(obj: any): obj is IToolboxItem { + return ( + obj && + typeof obj.init === 'function' && + typeof obj.getDiv === 'function' && + typeof obj.getId === 'function' && + typeof obj.getParent === 'function' && + typeof obj.getLevel === 'function' && + typeof obj.isSelectable === 'function' && + typeof obj.isCollapsible === 'function' && + typeof obj.dispose === 'function' && + typeof obj.getClickTarget === 'function' && + typeof obj.setVisible_ === 'function' && + typeof obj.getParentToolbox === 'function' + ); } diff --git a/packages/blockly/core/keyboard_nav/block_navigation_policy.ts b/packages/blockly/core/keyboard_nav/block_navigation_policy.ts deleted file mode 100644 index 9f56b538455..00000000000 --- a/packages/blockly/core/keyboard_nav/block_navigation_policy.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {BlockSvg} from '../block_svg.js'; -import {ConnectionType} from '../connection_type.js'; -import type {Field} from '../field.js'; -import type {Icon} from '../icons/icon.js'; -import type {IBoundedElement} from '../interfaces/i_bounded_element.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import {isFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import type {ISelectable} from '../interfaces/i_selectable.js'; -import {RenderedConnection} from '../rendered_connection.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; - -/** - * Set of rules controlling keyboard navigation from a block. - */ -export class BlockNavigationPolicy implements INavigationPolicy { - /** - * Returns the first child of the given block. - * - * @param current The block to return the first child of. - * @returns The first field or input of the given block, if any. - */ - getFirstChild(current: BlockSvg): IFocusableNode | null { - const candidates = getBlockNavigationCandidates(current, true); - return candidates[0]; - } - - /** - * Returns the parent of the given block. - * - * @param current The block to return the parent of. - * @returns The top block of the given block's stack, or the connection to - * which it is attached. - */ - getParent(current: BlockSvg): IFocusableNode | null { - if (current.previousConnection?.targetBlock()) { - const surroundParent = current.getSurroundParent(); - if (surroundParent) return surroundParent; - } else if (current.outputConnection?.targetBlock()) { - return current.outputConnection.targetBlock(); - } - - return current.workspace; - } - - /** - * Returns the next peer node of the given block. - * - * @param current The block to find the following element of. - * @returns The first node of the next input/stack if the given block is a terminal - * block, or its next connection. - */ - getNextSibling(current: BlockSvg): IFocusableNode | null { - if (current.nextConnection?.targetBlock()) { - return current.nextConnection?.targetBlock(); - } else if (current.outputConnection?.targetBlock()) { - return navigateBlock(current, 1); - } else if (current.getSurroundParent()) { - return navigateBlock(current.getTopStackBlock(), 1); - } else if (this.getParent(current) instanceof WorkspaceSvg) { - return navigateStacks(current, 1); - } - - return null; - } - - /** - * Returns the previous peer node of the given block. - * - * @param current The block to find the preceding element of. - * @returns The block's previous/output connection, or the last - * connection/block of the previous block stack if it is a root block. - */ - getPreviousSibling(current: BlockSvg): IFocusableNode | null { - if (current.previousConnection?.targetBlock()) { - return current.previousConnection?.targetBlock(); - } else if (current.outputConnection?.targetBlock()) { - return navigateBlock(current, -1); - } else if (this.getParent(current) instanceof WorkspaceSvg) { - return navigateStacks(current, -1); - } - - return null; - } - - /** - * Returns whether or not the given block can be navigated to. - * - * @param current The instance to check for navigability. - * @returns True if the given block can be focused. - */ - isNavigable(current: BlockSvg): boolean { - return current.canBeFocused(); - } - - /** - * Returns whether the given object can be navigated from by this policy. - * - * @param current The object to check if this policy applies to. - * @returns True if the object is a BlockSvg. - */ - isApplicable(current: any): current is BlockSvg { - return current instanceof BlockSvg; - } -} - -/** - * Returns a list of the navigable children of the given block. - * - * @param block The block to retrieve the navigable children of. - * @returns A list of navigable/focusable children of the given block. - */ -function getBlockNavigationCandidates( - block: BlockSvg, - forward: boolean, -): IFocusableNode[] { - const candidates: IFocusableNode[] = block.getIcons(); - - for (const input of block.inputList) { - if (!input.isVisible()) continue; - candidates.push(...input.fieldRow); - if (input.connection?.targetBlock()) { - const connectedBlock = input.connection.targetBlock() as BlockSvg; - if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) { - const lastStackBlock = connectedBlock - .lastConnectionInStack(false) - ?.getSourceBlock(); - if (lastStackBlock) { - candidates.push(lastStackBlock); - } - } else { - candidates.push(connectedBlock); - } - } else if (input.connection?.type === ConnectionType.INPUT_VALUE) { - candidates.push(input.connection as RenderedConnection); - } - } - - return candidates; -} - -/** - * Returns the next/previous stack relative to the given element's stack. - * - * @param current The element whose stack will be navigated relative to. - * @param delta The difference in index to navigate; positive values navigate - * to the nth next stack, while negative values navigate to the nth previous - * stack. - * @returns The first element in the stack offset by `delta` relative to the - * current element's stack, or the last element in the stack offset by - * `delta` relative to the current element's stack when navigating backwards. - */ -export function navigateStacks(current: ISelectable, delta: number) { - const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg) - .getTopBoundedElements(true) - .filter((element: IBoundedElement) => isFocusableNode(element)); - const currentIndex = stacks.indexOf( - current instanceof BlockSvg ? current.getRootBlock() : current, - ); - const targetIndex = currentIndex + delta; - let result: IFocusableNode | null = null; - if (targetIndex >= 0 && targetIndex < stacks.length) { - result = stacks[targetIndex]; - } else if (targetIndex < 0) { - result = stacks[stacks.length - 1]; - } else if (targetIndex >= stacks.length) { - result = stacks[0]; - } - - // When navigating to a previous block stack, our previous sibling is the last - // block in it. - if (delta < 0 && result instanceof BlockSvg) { - return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; - } - - return result; -} - -/** - * Returns the next navigable item relative to the provided block child. - * - * @param current The navigable block child item to navigate relative to. - * @param delta The difference in index to navigate; positive values navigate - * forward by n, while negative values navigate backwards by n. - * @returns The navigable block child offset by `delta` relative to `current`. - */ -export function navigateBlock( - current: Icon | Field | RenderedConnection | BlockSvg, - delta: number, -): IFocusableNode | null { - const block = - current instanceof BlockSvg - ? (current.outputConnection?.targetBlock() ?? current.getSurroundParent()) - : current.getSourceBlock(); - if (!(block instanceof BlockSvg)) return null; - - const candidates = getBlockNavigationCandidates(block, delta > 0); - const currentIndex = candidates.indexOf(current); - if (currentIndex === -1) return null; - - const targetIndex = currentIndex + delta; - if (targetIndex >= 0 && targetIndex < candidates.length) { - return candidates[targetIndex]; - } - - return null; -} diff --git a/packages/blockly/core/keyboard_nav/connection_navigation_policy.ts b/packages/blockly/core/keyboard_nav/connection_navigation_policy.ts deleted file mode 100644 index bf685d0635c..00000000000 --- a/packages/blockly/core/keyboard_nav/connection_navigation_policy.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {BlockSvg} from '../block_svg.js'; -import {ConnectionType} from '../connection_type.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import {RenderedConnection} from '../rendered_connection.js'; -import {navigateBlock} from './block_navigation_policy.js'; - -/** - * Set of rules controlling keyboard navigation from a connection. - */ -export class ConnectionNavigationPolicy - implements INavigationPolicy -{ - /** - * Returns the first child of the given connection. - * - * @param current The connection to return the first child of. - * @returns The connection's first child element, or null if not none. - */ - getFirstChild(current: RenderedConnection): IFocusableNode | null { - if (current.getParentInput()) { - return current.targetConnection; - } - - return null; - } - - /** - * Returns the parent of the given connection. - * - * @param current The connection to return the parent of. - * @returns The given connection's parent connection or block. - */ - getParent(current: RenderedConnection): IFocusableNode | null { - return current.getSourceBlock(); - } - - /** - * Returns the next element following the given connection. - * - * @param current The connection to navigate from. - * @returns The field, input connection or block following this connection. - */ - getNextSibling(current: RenderedConnection): IFocusableNode | null { - if (current.getParentInput()) { - return navigateBlock(current, 1); - } else if (current.type === ConnectionType.NEXT_STATEMENT) { - const nextBlock = current.targetConnection; - // If this connection is the last one in the stack, our next sibling is - // the next block stack. - const sourceBlock = current.getSourceBlock(); - if ( - !nextBlock && - sourceBlock.getRootBlock().lastConnectionInStack(false) === current - ) { - const topBlocks = sourceBlock.workspace.getTopBlocks(true); - let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) + 1; - if (targetIndex >= topBlocks.length) { - targetIndex = 0; - } - const nextBlock = topBlocks[targetIndex]; - return this.getParentConnection(nextBlock) ?? nextBlock; - } - - return nextBlock; - } - - return current.getSourceBlock(); - } - - /** - * Returns the element preceding the given connection. - * - * @param current The connection to navigate from. - * @returns The field, input connection or block preceding this connection. - */ - getPreviousSibling(current: RenderedConnection): IFocusableNode | null { - if (current.getParentInput()) { - return navigateBlock(current, -1); - } else if ( - current.type === ConnectionType.PREVIOUS_STATEMENT || - current.type === ConnectionType.OUTPUT_VALUE - ) { - const previousConnection = - current.targetConnection && !current.targetConnection.getParentInput() - ? current.targetConnection - : null; - - // If this connection is a disconnected previous/output connection, our - // previous sibling is the previous block stack's last connection/block. - const sourceBlock = current.getSourceBlock(); - if ( - !previousConnection && - this.getParentConnection(sourceBlock.getRootBlock()) === current - ) { - const topBlocks = sourceBlock.workspace.getTopBlocks(true); - let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) - 1; - if (targetIndex < 0) { - targetIndex = topBlocks.length - 1; - } - const previousRootBlock = topBlocks[targetIndex]; - return ( - previousRootBlock.lastConnectionInStack(false) ?? previousRootBlock - ); - } - - return previousConnection; - } else if (current.type === ConnectionType.NEXT_STATEMENT) { - return current.getSourceBlock(); - } - return null; - } - - /** - * Gets the parent connection on a block. - * This is either an output connection, previous connection or undefined. - * If both connections exist return the one that is actually connected - * to another block. - * - * @param block The block to find the parent connection on. - * @returns The connection connecting to the parent of the block. - */ - protected getParentConnection(block: BlockSvg) { - if (!block.outputConnection || block.previousConnection?.isConnected()) { - return block.previousConnection; - } - return block.outputConnection; - } - - /** - * Returns whether or not the given connection can be navigated to. - * - * @param current The instance to check for navigability. - * @returns True if the given connection can be focused. - */ - isNavigable(current: RenderedConnection): boolean { - return current.canBeFocused(); - } - - /** - * Returns whether the given object can be navigated from by this policy. - * - * @param current The object to check if this policy applies to. - * @returns True if the object is a RenderedConnection. - */ - isApplicable(current: any): current is RenderedConnection { - return current instanceof RenderedConnection; - } -} diff --git a/packages/blockly/core/keyboard_nav/flyout_navigation_policy.ts b/packages/blockly/core/keyboard_nav/flyout_navigation_policy.ts deleted file mode 100644 index 6552c27b499..00000000000 --- a/packages/blockly/core/keyboard_nav/flyout_navigation_policy.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {IFlyout} from '../interfaces/i_flyout.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; - -/** - * Generic navigation policy that navigates between items in the flyout. - */ -export class FlyoutNavigationPolicy implements INavigationPolicy { - /** - * Creates a new FlyoutNavigationPolicy instance. - * - * @param policy The policy to defer to for parents/children. - * @param flyout The flyout this policy will control navigation in. - */ - constructor( - private policy: INavigationPolicy, - private flyout: IFlyout, - ) {} - - /** - * Returns null to prevent navigating into flyout items. - * - * @param _current The flyout item to navigate from. - * @returns Null to prevent navigating into flyout items. - */ - getFirstChild(_current: T): IFocusableNode | null { - return null; - } - - /** - * Returns the parent of the given flyout item. - * - * @param current The flyout item to navigate from. - * @returns The parent of the given flyout item. - */ - getParent(current: T): IFocusableNode | null { - return this.policy.getParent(current); - } - - /** - * Returns the next item in the flyout relative to the given item. - * - * @param current The flyout item to navigate from. - * @returns The flyout item following the given one. - */ - getNextSibling(current: T): IFocusableNode | null { - const flyoutContents = this.flyout.getContents(); - if (!flyoutContents) return null; - - let index = flyoutContents.findIndex( - (flyoutItem) => flyoutItem.getElement() === current, - ); - - if (index === -1) return null; - index++; - if (index >= flyoutContents.length) { - index = 0; - } - - return flyoutContents[index].getElement(); - } - - /** - * Returns the previous item in the flyout relative to the given item. - * - * @param current The flyout item to navigate from. - * @returns The flyout item preceding the given one. - */ - getPreviousSibling(current: T): IFocusableNode | null { - const flyoutContents = this.flyout.getContents(); - if (!flyoutContents) return null; - - let index = flyoutContents.findIndex( - (flyoutItem) => flyoutItem.getElement() === current, - ); - - if (index === -1) return null; - index--; - if (index < 0) { - index = flyoutContents.length - 1; - } - - return flyoutContents[index].getElement(); - } - - /** - * Returns whether or not the given flyout item can be navigated to. - * - * @param current The instance to check for navigability. - * @returns True if the given flyout item can be focused. - */ - isNavigable(current: T): boolean { - return this.policy.isNavigable(current); - } - - /** - * Returns whether the given object can be navigated from by this policy. - * - * @param current The object to check if this policy applies to. - * @returns True if the object is a BlockSvg. - */ - isApplicable(current: any): current is T { - return this.policy.isApplicable(current); - } -} diff --git a/packages/blockly/core/keyboard_nav/line_cursor.ts b/packages/blockly/core/keyboard_nav/line_cursor.ts deleted file mode 100644 index 30770e47d2d..00000000000 --- a/packages/blockly/core/keyboard_nav/line_cursor.ts +++ /dev/null @@ -1,414 +0,0 @@ -/** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview The class representing a line cursor. - * A line cursor tries to traverse the blocks and connections on a block as if - * they were lines of code in a text editor. Previous and next traverse previous - * connections, next connections and blocks, while in and out traverse input - * connections and fields. - * @author aschmiedt@google.com (Abby Schmiedt) - */ - -import {BlockSvg} from '../block_svg.js'; -import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; -import {getFocusManager} from '../focus_manager.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import * as registry from '../registry.js'; -import type {WorkspaceSvg} from '../workspace_svg.js'; -import {Marker} from './marker.js'; - -/** - * Class for a line cursor. - */ -export class LineCursor extends Marker { - override type = 'cursor'; - - /** Locations to try moving the cursor to after a deletion. */ - private potentialNodes: IFocusableNode[] | null = null; - - /** - * @param workspace The workspace this cursor belongs to. - */ - constructor(protected readonly workspace: WorkspaceSvg) { - super(); - } - - /** - * Moves the cursor to the next block or workspace comment in the pre-order - * traversal. - * - * @returns The next node, or null if the current node is not set or there is - * no next value. - */ - next(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - const newNode = this.getNextNode( - curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, - true, - ); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Moves the cursor to the next input connection or field - * in the pre order traversal. - * - * @returns The next node, or null if the current node is - * not set or there is no next value. - */ - in(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - - const newNode = this.getNextNode(curNode, () => true, true); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - /** - * Moves the cursor to the previous block or workspace comment in the - * pre-order traversal. - * - * @returns The previous node, or null if the current node is not set or there - * is no previous value. - */ - prev(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - const newNode = this.getPreviousNode( - curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, - true, - ); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Moves the cursor to the previous input connection or field in the pre order - * traversal. - * - * @returns The previous node, or null if the current node - * is not set or there is no previous value. - */ - out(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - - const newNode = this.getPreviousNode(curNode, () => true, true); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Returns true iff the node to which we would navigate if in() were - * called is the same as the node to which we would navigate if next() were - * called - in effect, if the LineCursor is at the end of the 'current - * line' of the program. - */ - atEndOfLine(): boolean { - const curNode = this.getCurNode(); - if (!curNode) return false; - const inNode = this.getNextNode(curNode, () => true, true); - const nextNode = this.getNextNode( - curNode, - (candidate: IFocusableNode | null) => { - return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() - ); - }, - true, - ); - - return inNode === nextNode; - } - - /** - * Uses pre order traversal to navigate the Blockly AST. This will allow - * a user to easily navigate the entire Blockly AST without having to go in - * and out levels on the tree. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @param visitedNodes A set of previously visited nodes used to avoid cycles. - * @returns The next node in the traversal. - */ - private getNextNodeImpl( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - visitedNodes: Set = new Set(), - ): IFocusableNode | null { - if (!node || visitedNodes.has(node)) return null; - let newNode = - this.workspace.getNavigator().getFirstChild(node) || - this.workspace.getNavigator().getNextSibling(node); - - let target = node; - while (target && !newNode) { - const parent = this.workspace.getNavigator().getParent(target); - if (!parent) break; - newNode = this.workspace.getNavigator().getNextSibling(parent); - target = parent; - } - - if (isValid(newNode)) return newNode; - if (newNode) { - visitedNodes.add(node); - return this.getNextNodeImpl(newNode, isValid, visitedNodes); - } - return null; - } - - /** - * Get the next node in the AST, optionally allowing for loopback. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @param loop Whether to loop around to the beginning of the workspace if no - * valid node was found. - * @returns The next node in the traversal. - */ - getNextNode( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - loop: boolean, - ): IFocusableNode | null { - if (!node || (!loop && this.getLastNode() === node)) return null; - - return this.getNextNodeImpl(node, isValid); - } - - /** - * Reverses the pre order traversal in order to find the previous node. This - * will allow a user to easily navigate the entire Blockly AST without having - * to go in and out levels on the tree. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @param visitedNodes A set of previously visited nodes used to avoid cycles. - * @returns The previous node in the traversal or null if no previous node - * exists. - */ - private getPreviousNodeImpl( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - visitedNodes: Set = new Set(), - ): IFocusableNode | null { - if (!node || visitedNodes.has(node)) return null; - - const newNode = - this.getRightMostChild( - this.workspace.getNavigator().getPreviousSibling(node), - node, - ) || this.workspace.getNavigator().getParent(node); - - if (isValid(newNode)) return newNode; - if (newNode) { - visitedNodes.add(node); - return this.getPreviousNodeImpl(newNode, isValid, visitedNodes); - } - return null; - } - - /** - * Get the previous node in the AST, optionally allowing for loopback. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @param loop Whether to loop around to the end of the workspace if no valid - * node was found. - * @returns The previous node in the traversal or null if no previous node - * exists. - */ - getPreviousNode( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - loop: boolean, - ): IFocusableNode | null { - if (!node || (!loop && this.getFirstNode() === node)) return null; - - return this.getPreviousNodeImpl(node, isValid); - } - - /** - * Get the right most child of a node. - * - * @param node The node to find the right most child of. - * @returns The right most child of the given node, or the node if no child - * exists. - */ - private getRightMostChild( - node: IFocusableNode | null, - stopIfFound: IFocusableNode, - ): IFocusableNode | null { - if (!node) return node; - let newNode = this.workspace.getNavigator().getFirstChild(node); - if (!newNode || newNode === stopIfFound) return node; - for ( - let nextNode: IFocusableNode | null = newNode; - nextNode; - nextNode = this.workspace.getNavigator().getNextSibling(newNode) - ) { - if (nextNode === stopIfFound) break; - newNode = nextNode; - } - return this.getRightMostChild(newNode, stopIfFound); - } - - /** - * Prepare for the deletion of a block by making a list of nodes we - * could move the cursor to afterwards and save it to - * this.potentialNodes. - * - * After the deletion has occurred, call postDelete to move it to - * the first valid node on that list. - * - * The locations to try (in order of preference) are: - * - * - The current location. - * - The connection to which the deleted block is attached. - * - The block connected to the next connection of the deleted block. - * - The parent block of the deleted block. - * - A location on the workspace beneath the deleted block. - * - * N.B.: When block is deleted, all of the blocks conneccted to that - * block's inputs are also deleted, but not blocks connected to its - * next connection. - * - * @param deletedBlock The block that is being deleted. - */ - preDelete(deletedBlock: BlockSvg) { - const curNode = this.getCurNode(); - - const nodes: IFocusableNode[] = curNode ? [curNode] : []; - // The connection to which the deleted block is attached. - const parentConnection = - deletedBlock.previousConnection?.targetConnection ?? - deletedBlock.outputConnection?.targetConnection; - if (parentConnection) { - nodes.push(parentConnection); - } - // The block connected to the next connection of the deleted block. - const nextBlock = deletedBlock.getNextBlock(); - if (nextBlock) { - nodes.push(nextBlock); - } - // The parent block of the deleted block. - const parentBlock = deletedBlock.getParent(); - if (parentBlock) { - nodes.push(parentBlock); - } - // A location on the workspace beneath the deleted block. - // Move to the workspace. - nodes.push(this.workspace); - this.potentialNodes = nodes; - } - - /** - * Move the cursor to the first valid location in - * this.potentialNodes, following a block deletion. - */ - postDelete() { - const nodes = this.potentialNodes; - this.potentialNodes = null; - if (!nodes) throw new Error('must call preDelete first'); - for (const node of nodes) { - if (!this.getSourceBlockFromNode(node)?.disposed) { - this.setCurNode(node); - return; - } - } - throw new Error('no valid nodes in this.potentialNodes'); - } - - /** - * Get the current location of the cursor. - * - * Overrides normal Marker getCurNode to update the current node from the - * selected block. This typically happens via the selection listener but that - * is not called immediately when `Gesture` calls - * `Blockly.common.setSelected`. In particular the listener runs after showing - * the context menu. - * - * @returns The current field, connection, or block the cursor is on. - */ - getCurNode(): IFocusableNode | null { - return getFocusManager().getFocusedNode(); - } - - /** - * Set the location of the cursor and draw it. - * - * Overrides normal Marker setCurNode logic to call - * this.drawMarker() instead of this.drawer.draw() directly. - * - * @param newNode The new location of the cursor. - */ - setCurNode(newNode: IFocusableNode) { - getFocusManager().focusNode(newNode); - } - - /** - * Get the first navigable node on the workspace, or null if none exist. - * - * @returns The first navigable node on the workspace, or null. - */ - getFirstNode(): IFocusableNode | null { - return this.workspace.getNavigator().getFirstChild(this.workspace); - } - - /** - * Get the last navigable node on the workspace, or null if none exist. - * - * @returns The last navigable node on the workspace, or null. - */ - getLastNode(): IFocusableNode | null { - const first = this.getFirstNode(); - return this.getPreviousNode(first, () => true, true); - } -} - -registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor); diff --git a/packages/blockly/core/keyboard_nav/marker.ts b/packages/blockly/core/keyboard_nav/marker.ts deleted file mode 100644 index 0cd066c163c..00000000000 --- a/packages/blockly/core/keyboard_nav/marker.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * The class representing a marker. - * Used primarily for keyboard navigation to show a marked location. - * - * @class - */ -// Former goog.module ID: Blockly.Marker - -import {BlockSvg} from '../block_svg.js'; -import {Field} from '../field.js'; -import {Icon} from '../icons/icon.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import {RenderedConnection} from '../rendered_connection.js'; - -/** - * Class for a marker. - * This is used in keyboard navigation to save a location in the Blockly AST. - */ -export class Marker { - /** The colour of the marker. */ - colour: string | null = null; - - /** The current location of the marker. */ - protected curNode: IFocusableNode | null = null; - - /** The type of the marker. */ - type = 'marker'; - - /** - * Gets the current location of the marker. - * - * @returns The current field, connection, or block the marker is on. - */ - getCurNode(): IFocusableNode | null { - return this.curNode; - } - - /** - * Set the location of the marker and call the update method. - * - * @param newNode The new location of the marker, or null to remove it. - */ - setCurNode(newNode: IFocusableNode | null) { - this.curNode = newNode; - } - - /** Dispose of this marker. */ - dispose() { - this.curNode = null; - } - - /** - * Returns the block that the given node is a child of. - * - * @returns The parent block of the node if any, otherwise null. - */ - getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null { - if (node instanceof BlockSvg) { - return node; - } else if (node instanceof Field) { - return node.getSourceBlock() as BlockSvg; - } else if (node instanceof RenderedConnection) { - return node.getSourceBlock(); - } else if (node instanceof Icon) { - return node.getSourceBlock() as BlockSvg; - } - - return null; - } - - /** - * Returns the block that this marker's current node is a child of. - * - * @returns The parent block of the marker's current node if any, otherwise - * null. - */ - getSourceBlock(): BlockSvg | null { - return this.getSourceBlockFromNode(this.getCurNode()); - } -} diff --git a/packages/blockly/core/keyboard_nav/block_comment_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts similarity index 81% rename from packages/blockly/core/keyboard_nav/block_comment_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts index f2f1ab7e107..36b15db243b 100644 --- a/packages/blockly/core/keyboard_nav/block_comment_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {TextInputBubble} from '../bubbles/textinput_bubble.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {TextInputBubble} from '../../bubbles/textinput_bubble.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from an TextInputBubble. @@ -54,6 +54,16 @@ export class BlockCommentNavigationPolicy return null; } + /** + * Returns the row ID of the given block comment. + * + * @param current The block comment to retrieve the row ID of. + * @returns The row ID of the given block comment. + */ + getRowId(current: TextInputBubble) { + return current.id; + } + /** * Returns whether or not the given block comment can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts new file mode 100644 index 00000000000..183c92e7b84 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../../block_svg.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../../rendered_connection.js'; + +/** + * Set of rules controlling keyboard navigation from a block. + */ +export class BlockNavigationPolicy implements INavigationPolicy { + /** + * Returns the first child of the given block. + * + * @param current The block to return the first child of. + * @returns The first icon, field, or input of the given block, if any. + */ + getFirstChild(current: BlockSvg): IFocusableNode | null { + return getBlockNavigationCandidates(current)[0]; + } + + /** + * Returns the parent of the given block. + * + * @param current The block to return the parent of. + * @returns The top block of the given block's stack, or the connection to + * which it is attached. + */ + getParent(current: BlockSvg): IFocusableNode | null { + if (current.previousConnection?.targetBlock()) { + const surroundParent = current.getSurroundParent(); + if (surroundParent) return surroundParent; + } else if (current.outputConnection?.targetBlock()) { + return current.outputConnection.targetBlock(); + } + + return current.workspace; + } + + /** + * Returns the next peer node of the given block. + * + * @param current The block to find the following element of. + * @returns The block's next connection, or the next peer on its parent block, + * otherwise null. + */ + getNextSibling(current: BlockSvg): IFocusableNode | null { + if (current.nextConnection) { + return current.nextConnection.targetBlock() ?? current.nextConnection; + } else if (current.outputConnection?.targetConnection) { + const parent = this.getParent(current) as BlockSvg; + return navigateBlock(parent, current, 1); + } + + return null; + } + + /** + * Returns the previous peer node of the given block. + * + * @param current The block to find the preceding element of. + * @returns The block's previous connection, or the previous peer on its + * parent block, otherwise null. + */ + getPreviousSibling(current: BlockSvg): IFocusableNode | null { + if (current.previousConnection?.targetBlock()) { + return current.previousConnection; + } else if (current.outputConnection?.targetBlock()) { + return navigateBlock( + current.outputConnection.targetBlock()!, + current, + -1, + ); + } + + return null; + } + + /** + * Returns the visual row ID of the given block. + * + * @param current The block to retrieve the row ID of. + * @returns The row ID of the given block. + */ + getRowId(current: BlockSvg) { + return current.getRowId(); + } + + /** + * Returns whether or not the given block can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given block can be focused. + */ + isNavigable(current: BlockSvg): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a BlockSvg. + */ + isApplicable(current: any): current is BlockSvg { + return current instanceof BlockSvg; + } +} + +/** + * Returns a list of the navigable children of the given block. + * + * @param block The block to retrieve the navigable children of. + * @returns A list of navigable/focusable children of the given block. + */ +function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] { + // Collapsed blocks have no navigable children. + if (block.isCollapsed()) return []; + + // Icons are navigable. + const candidates: IFocusableNode[] = block.getIcons(); + + for (const input of block.inputList) { + // Invisible inputs are not valid navigation candidates. + if (!input.isVisible()) continue; + + // Fields are navigable. + candidates.push(...input.fieldRow); + + // Connections on inputs are navigable. + const connection = input.connection; + if (!connection) continue; + candidates.push(connection as RenderedConnection); + + // Child blocks attached to inputs are navigable. + const attachedBlock = connection.targetBlock(); + if (!attachedBlock) continue; + candidates.push(attachedBlock as BlockSvg); + + // The last (empty) next connection in a child statement block stack is + // navigable. + const lastConnection = attachedBlock.lastConnectionInStack(false); + if (!lastConnection) continue; + candidates.push(lastConnection as RenderedConnection); + } + + // The block's next connection is navigable. + if (block.nextConnection) { + candidates.push(block.nextConnection); + } + + return candidates; +} + +/** + * Returns the next navigable item relative to the provided block child. + * + * @param block The block whose children should be navigated. + * @param current The navigable block child item to navigate relative to. + * @param delta The difference in index to navigate; positive values navigate + * forward by n, while negative values navigate backwards by n. + * @returns The navigable block child offset by `delta` relative to `current`. + */ +export function navigateBlock( + block: BlockSvg, + current: IFocusableNode, + delta: number, +): IFocusableNode | null { + const candidates = getBlockNavigationCandidates(block); + const currentIndex = candidates.indexOf(current); + if (currentIndex === -1) return null; + + const targetIndex = currentIndex + delta; + if (targetIndex >= 0 && targetIndex < candidates.length) { + return candidates[targetIndex]; + } + + return null; +} diff --git a/packages/blockly/core/keyboard_nav/comment_bar_button_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.ts similarity index 83% rename from packages/blockly/core/keyboard_nav/comment_bar_button_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.ts index 6654d2d8fef..c03ddd925f9 100644 --- a/packages/blockly/core/keyboard_nav/comment_bar_button_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {CommentBarButton} from '../comments/comment_bar_button.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {CommentBarButton} from '../../comments/comment_bar_button.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a CommentBarButton. @@ -66,6 +66,16 @@ export class CommentBarButtonNavigationPolicy return null; } + /** + * Returns the row ID of the given CommentBarButton. + * + * @param current The CommentBarButton to retrieve the row ID of. + * @returns The row ID of the given CommentBarButton. + */ + getRowId(current: CommentBarButton) { + return current.getCommentView().commentId; + } + /** * Returns whether or not the given CommentBarButton can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/comment_editor_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/comment_editor_navigation_policy.ts similarity index 73% rename from packages/blockly/core/keyboard_nav/comment_editor_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/comment_editor_navigation_policy.ts index 456df8e97c8..9c54824c8a5 100644 --- a/packages/blockly/core/keyboard_nav/comment_editor_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/comment_editor_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {CommentEditor} from '../comments/comment_editor.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {CommentEditor} from '../../comments/comment_editor.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a comment editor. @@ -32,6 +32,16 @@ export class CommentEditorNavigationPolicy return null; } + /** + * Returns the row ID of the given comment editor. + * + * @param current The comment editor to retrieve the row ID of. + * @returns The row ID of the given comment editor. + */ + getRowId(current: CommentEditor) { + return current.id; + } + /** * Returns whether or not the given comment editor can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/connection_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/connection_navigation_policy.ts new file mode 100644 index 00000000000..32a07e14a72 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigation_policies/connection_navigation_policy.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from '../../block_svg.js'; +import {ConnectionType} from '../../connection_type.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../../rendered_connection.js'; +import {navigateBlock} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a connection. + */ +export class ConnectionNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of a connection. + * + * @returns Null, as connections do not have children. + */ + getFirstChild(): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given connection. + * + * @param current The connection to return the parent of. + * @returns The given connection's parent block. + */ + getParent(current: RenderedConnection): IFocusableNode | null { + return current.getSourceBlock(); + } + + /** + * Returns the next element following the given connection. + * + * @param current The connection to navigate from. + * @returns The field, input connection or block following this connection. + */ + getNextSibling(current: RenderedConnection): IFocusableNode | null { + if (current.getParentInput()) { + return navigateBlock(current.getSourceBlock(), current, 1); + } else if ( + current.type === ConnectionType.NEXT_STATEMENT && + current.getSourceBlock().getSurroundParent() && + !current.targetConnection + ) { + return navigateBlock( + current.getSourceBlock().getSurroundParent()!, + current, + 1, + ); + } + + switch (current.type) { + case ConnectionType.NEXT_STATEMENT: + return current.targetConnection; + case ConnectionType.PREVIOUS_STATEMENT: + case ConnectionType.OUTPUT_VALUE: + return current.getSourceBlock(); + } + + return null; + } + + /** + * Returns the element preceding the given connection. + * + * @param current The connection to navigate from. + * @returns The field, input connection or block preceding this connection. + */ + getPreviousSibling(current: RenderedConnection): IFocusableNode | null { + if (current.getParentInput()) { + return navigateBlock( + current.getParentInput()!.getSourceBlock() as BlockSvg, + current, + -1, + ); + } + switch (current.type) { + case ConnectionType.NEXT_STATEMENT: + return current.getSourceBlock(); + case ConnectionType.PREVIOUS_STATEMENT: + case ConnectionType.OUTPUT_VALUE: + return current.targetConnection; + } + + return null; + } + + /** + * Returns the row ID of the given connection. + * + * @param current The connection to retrieve the row ID of. + * @returns The row ID of the given connection. + */ + getRowId(current: RenderedConnection) { + switch (current.type) { + case ConnectionType.NEXT_STATEMENT: + case ConnectionType.PREVIOUS_STATEMENT: + return current.id; + case ConnectionType.INPUT_VALUE: + return current.getParentInput()!.getRowId(); + case ConnectionType.OUTPUT_VALUE: + default: + return current.getSourceBlock().getRowId(); + } + } + + /** + * Returns whether or not the given connection can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given connection can be focused. + */ + isNavigable(current: RenderedConnection): boolean { + // Empty next connections on block stacks inside of a C shaped block are + // navigable. + if (current.type === ConnectionType.NEXT_STATEMENT) { + if (current.targetBlock()) return false; + + const rootBlock = + current.getSourceBlock().getRootBlock() ?? current.getSourceBlock(); + if (current === rootBlock.lastConnectionInStack(false)) return false; + + return true; + } + + // Empty input connections are navigable. + return ( + current.type === ConnectionType.INPUT_VALUE && !current.targetBlock() + ); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a RenderedConnection. + */ + isApplicable(current: any): current is RenderedConnection { + return current instanceof RenderedConnection; + } +} diff --git a/packages/blockly/core/keyboard_nav/field_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/field_navigation_policy.ts similarity index 77% rename from packages/blockly/core/keyboard_nav/field_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/field_navigation_policy.ts index f9df406c22c..6b0acff2eab 100644 --- a/packages/blockly/core/keyboard_nav/field_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/field_navigation_policy.ts @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {BlockSvg} from '../block_svg.js'; -import {Field} from '../field.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import type {BlockSvg} from '../../block_svg.js'; +import {Field} from '../../field.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; import {navigateBlock} from './block_navigation_policy.js'; /** @@ -41,7 +41,7 @@ export class FieldNavigationPolicy implements INavigationPolicy> { * @returns The next field or input in the given field's block. */ getNextSibling(current: Field): IFocusableNode | null { - return navigateBlock(current, 1); + return navigateBlock(current.getSourceBlock() as BlockSvg, current, 1); } /** @@ -51,7 +51,17 @@ export class FieldNavigationPolicy implements INavigationPolicy> { * @returns The preceding field or input in the given field's block. */ getPreviousSibling(current: Field): IFocusableNode | null { - return navigateBlock(current, -1); + return navigateBlock(current.getSourceBlock() as BlockSvg, current, -1); + } + + /** + * Returns the row ID of the given field. + * + * @param current The field to retrieve the row ID of. + * @returns The row ID of the given field. + */ + getRowId(current: Field) { + return current.getParentInput().getRowId(); } /** diff --git a/packages/blockly/core/keyboard_nav/flyout_button_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_button_navigation_policy.ts similarity index 80% rename from packages/blockly/core/keyboard_nav/flyout_button_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/flyout_button_navigation_policy.ts index 6c39c3061e7..40a2cf45ec5 100644 --- a/packages/blockly/core/keyboard_nav/flyout_button_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_button_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {FlyoutButton} from '../flyout_button.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {FlyoutButton} from '../../flyout_button.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a flyout button. @@ -54,6 +54,16 @@ export class FlyoutButtonNavigationPolicy return null; } + /** + * Returns the row ID of the given flyout button. + * + * @param current The flyout button to retrieve the row ID of. + * @returns The row ID of the given flyout button. + */ + getRowId(current: FlyoutButton) { + return current.getButtonText(); + } + /** * Returns whether or not the given flyout button can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/flyout_separator_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_separator_navigation_policy.ts similarity index 76% rename from packages/blockly/core/keyboard_nav/flyout_separator_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/flyout_separator_navigation_policy.ts index eb7ca4eb783..8ea16a0aea1 100644 --- a/packages/blockly/core/keyboard_nav/flyout_separator_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_separator_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {FlyoutSeparator} from '../flyout_separator.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {FlyoutSeparator} from '../../flyout_separator.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a flyout separator. @@ -31,6 +31,15 @@ export class FlyoutSeparatorNavigationPolicy return null; } + /** + * Returns the row ID of the given flyout separator. + * + * @returns Dummy row ID, as flyout separators are never navigable. + */ + getRowId() { + return 'error'; + } + /** * Returns whether or not the given flyout separator can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/icon_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts similarity index 73% rename from packages/blockly/core/keyboard_nav/icon_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts index 112239d0655..d0d2b5ad1eb 100644 --- a/packages/blockly/core/keyboard_nav/icon_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts @@ -4,13 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {BlockSvg} from '../block_svg.js'; -import {getFocusManager} from '../focus_manager.js'; -import {CommentIcon} from '../icons/comment_icon.js'; -import {Icon} from '../icons/icon.js'; -import {MutatorIcon} from '../icons/mutator_icon.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {BlockSvg} from '../../block_svg.js'; +import {getFocusManager} from '../../focus_manager.js'; +import {CommentIcon} from '../../icons/comment_icon.js'; +import {Icon} from '../../icons/icon.js'; +import {MutatorIcon} from '../../icons/mutator_icon.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; import {navigateBlock} from './block_navigation_policy.js'; /** @@ -58,7 +58,7 @@ export class IconNavigationPolicy implements INavigationPolicy { * @returns The next icon, field or input following this icon, if any. */ getNextSibling(current: Icon): IFocusableNode | null { - return navigateBlock(current, 1); + return navigateBlock(current.getSourceBlock() as BlockSvg, current, 1); } /** @@ -68,7 +68,17 @@ export class IconNavigationPolicy implements INavigationPolicy { * @returns The icon's previous icon, if any. */ getPreviousSibling(current: Icon): IFocusableNode | null { - return navigateBlock(current, -1); + return navigateBlock(current.getSourceBlock() as BlockSvg, current, -1); + } + + /** + * Returns the row ID of the given icon. + * + * @param current The icon to retrieve the row ID of. + * @returns The row ID of the given icon. + */ + getRowId(current: Icon) { + return (current.getSourceBlock() as BlockSvg).getRowId(); } /** diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/toolbox_item_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/toolbox_item_navigation_policy.ts new file mode 100644 index 00000000000..f1b55fac53c --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigation_policies/toolbox_item_navigation_policy.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {isCollapsibleToolboxItem} from '../../interfaces/i_collapsible_toolbox_item.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import { + isToolboxItem, + type IToolboxItem, +} from '../../interfaces/i_toolbox_item.js'; + +/** + * Set of rules controlling keyboard navigation from a toolbox item. + */ +export class ToolboxItemNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given toolbox item. + * + * @param current The toolbox item to return the first child of. + * @returns The child item of a collapsible toolbox item, otherwise null. + */ + getFirstChild(current: IToolboxItem): IFocusableNode | null { + if (isCollapsibleToolboxItem(current)) { + return current.getChildToolboxItems()[0]; + } + + return null; + } + + /** + * Returns the parent of the given toolbox item. + * + * @param current The toolbox item to return the parent of. + * @returns The parent toolbox item of the given toolbox item, if any. + */ + getParent(current: IToolboxItem): IFocusableNode | null { + return current.getParent(); + } + + /** + * Returns the next sibling of the given toolbox item. + * + * @param current The toolbox item to return the next sibling of. + * @returns The next toolbox item, or null. + */ + getNextSibling(current: IToolboxItem): IFocusableNode | null { + const items = current.getParentToolbox().getToolboxItems(); + const index = items.indexOf(current); + return items[index + 1] ?? null; + } + + /** + * Returns the previous sibling of the given toolbox item. + * + * @param current The toolbox item to return the previous sibling of. + * @returns The previous toolbox item, or null. + */ + getPreviousSibling(current: IToolboxItem): IFocusableNode | null { + const items = current.getParentToolbox().getToolboxItems(); + const index = items.indexOf(current); + return items[index - 1] ?? null; + } + + /** + * Returns the row ID of the given toolbox item. + * + * @param current The toolbox item to retrieve the row ID of. + * @returns The row ID of the given toolbox item. + */ + getRowId(current: IToolboxItem) { + return current.getId(); + } + + /** + * Returns whether or not the given toolbox item can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given toolbox item can be focused. + */ + isNavigable(current: IToolboxItem): boolean { + return current.canBeFocused() && this.allParentsExpanded(current); + } + + private allParentsExpanded(current: IToolboxItem): boolean { + const parent = current.getParent(); + if (!parent || !isCollapsibleToolboxItem(parent)) return true; + + return parent.isExpanded() && this.allParentsExpanded(parent); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an IToolboxItem. + */ + isApplicable(current: any): current is IToolboxItem { + return isToolboxItem(current); + } +} diff --git a/packages/blockly/core/keyboard_nav/workspace_comment_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_comment_navigation_policy.ts similarity index 68% rename from packages/blockly/core/keyboard_nav/workspace_comment_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/workspace_comment_navigation_policy.ts index 7fe70ceadef..ce4a652347c 100644 --- a/packages/blockly/core/keyboard_nav/workspace_comment_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_comment_navigation_policy.ts @@ -4,10 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import {navigateStacks} from './block_navigation_policy.js'; +import {RenderedWorkspaceComment} from '../../comments/rendered_workspace_comment.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from an RenderedWorkspaceComment. @@ -38,21 +37,29 @@ export class WorkspaceCommentNavigationPolicy /** * Returns the next peer node of the given workspace comment. * - * @param current The workspace comment to find the following element of. - * @returns The next workspace comment or block stack, if any. + * @returns Null, as workspace comments do not have peers. */ - getNextSibling(current: RenderedWorkspaceComment): IFocusableNode | null { - return navigateStacks(current, 1); + getNextSibling(): IFocusableNode | null { + return null; } /** * Returns the previous peer node of the given workspace comment. * - * @param current The workspace comment to find the preceding element of. - * @returns The previous workspace comment or block stack, if any. + * @returns Null, as workspace comments do not have peers. */ - getPreviousSibling(current: RenderedWorkspaceComment): IFocusableNode | null { - return navigateStacks(current, -1); + getPreviousSibling(): IFocusableNode | null { + return null; + } + + /** + * Returns the row ID of the given workspace comment. + * + * @param current The workspace comment to retrieve the row ID of. + * @returns The row ID of the given workspace comment. + */ + getRowId(current: RenderedWorkspaceComment) { + return current.id; } /** diff --git a/packages/blockly/core/keyboard_nav/workspace_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_navigation_policy.ts similarity index 81% rename from packages/blockly/core/keyboard_nav/workspace_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/workspace_navigation_policy.ts index b671f8fe739..a2af54105e0 100644 --- a/packages/blockly/core/keyboard_nav/workspace_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {WorkspaceSvg} from '../../workspace_svg.js'; /** * Set of rules controlling keyboard navigation from a workspace. @@ -55,6 +55,16 @@ export class WorkspaceNavigationPolicy return null; } + /** + * Returns the row ID of the given workspace. + * + * @param current The workspace to retrieve the row ID of. + * @returns The row ID of the given workspace. + */ + getRowId(current: WorkspaceSvg) { + return current.id; + } + /** * Returns whether or not the given workspace can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts new file mode 100644 index 00000000000..9e2cd8bdcbf --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {IFocusableNode} from '../../blockly.js'; +import type {IFlyout} from '../../interfaces/i_flyout.js'; +import {FlyoutButtonNavigationPolicy} from '../navigation_policies/flyout_button_navigation_policy.js'; +import {FlyoutSeparatorNavigationPolicy} from '../navigation_policies/flyout_separator_navigation_policy.js'; +import {Navigator} from './navigator.js'; + +/** + * Navigator that handles keyboard navigation within a flyout. + */ +export class FlyoutNavigator extends Navigator { + constructor(protected flyout: IFlyout) { + super(); + this.rules.push( + new FlyoutButtonNavigationPolicy(), + new FlyoutSeparatorNavigationPolicy(), + ); + } + + /** + * Returns the toolbox when navigating to the left in a flyout. + */ + override getOutNode(): IFocusableNode | null { + const toolbox = this.flyout.targetWorkspace?.getToolbox(); + if (toolbox) return toolbox.getSelectedItem(); + + return null; + } + + /** + * Returns a list of top-level navigable flyout items. + */ + protected override getTopLevelItems(): IFocusableNode[] { + return this.flyout + .getContents() + .map((item) => item.getElement()) + .filter((element) => this.isNavigable(element)); + } + + /** + * Returns whether or not the given node is navigable. + * + * @param node A focusable node to check the navigability of. + * @returns True if the node is navigable, otherwise false. + */ + protected override isNavigable(node: IFocusableNode) { + return ( + super.isNavigable(node) && + this.flyout + .getContents() + .map((item): IFocusableNode => item.getElement()) + .includes(node) + ); + } +} diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts new file mode 100644 index 00000000000..36b72ab7d29 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -0,0 +1,526 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../../block_svg.js'; +import {ConnectionType} from '../../connection_type.js'; +import {Field} from '../../field.js'; +import {getFocusManager} from '../../focus_manager.js'; +import {Icon} from '../../icons/icon.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../../rendered_connection.js'; +import {BlockCommentNavigationPolicy} from '../navigation_policies/block_comment_navigation_policy.js'; +import {BlockNavigationPolicy} from '../navigation_policies/block_navigation_policy.js'; +import {CommentBarButtonNavigationPolicy} from '../navigation_policies/comment_bar_button_navigation_policy.js'; +import {CommentEditorNavigationPolicy} from '../navigation_policies/comment_editor_navigation_policy.js'; +import {ConnectionNavigationPolicy} from '../navigation_policies/connection_navigation_policy.js'; +import {FieldNavigationPolicy} from '../navigation_policies/field_navigation_policy.js'; +import {IconNavigationPolicy} from '../navigation_policies/icon_navigation_policy.js'; +import {WorkspaceCommentNavigationPolicy} from '../navigation_policies/workspace_comment_navigation_policy.js'; +import {WorkspaceNavigationPolicy} from '../navigation_policies/workspace_navigation_policy.js'; + +type RuleList = INavigationPolicy[]; + +/** + * Representation of the direction of travel within a navigation context. + */ +export enum NavigationDirection { + NEXT, + PREVIOUS, + IN, + OUT, +} + +/** + * Class responsible for determining where focus should move in response to + * keyboard navigation commands. + */ +export class Navigator { + /** + * Map from classes to a corresponding ruleset to handle navigation from + * instances of that class. + */ + protected rules: RuleList = [ + new BlockNavigationPolicy(), + new FieldNavigationPolicy(), + new ConnectionNavigationPolicy(), + new WorkspaceNavigationPolicy(), + new IconNavigationPolicy(), + new WorkspaceCommentNavigationPolicy(), + new CommentBarButtonNavigationPolicy(), + new BlockCommentNavigationPolicy(), + new CommentEditorNavigationPolicy(), + ]; + + /** Whether or not navigation loops around when reaching the end. */ + protected navigationLoops = false; + + /** + * Adds a navigation ruleset to this Navigator. + * + * @param policy A ruleset that determines where focus should move starting + * from an instance of its managed class. + */ + addNavigationPolicy(policy: INavigationPolicy) { + this.rules.push(policy); + } + + /** + * Returns the navigation ruleset associated with the given object instance's + * class. + * + * @param current An object to retrieve a navigation ruleset for. + * @returns The navigation ruleset of objects of the given object's class, or + * undefined if no ruleset has been registered for the object's class. + */ + private get( + current: IFocusableNode, + ): INavigationPolicy | undefined { + return this.rules.find((rule) => rule.isApplicable(current)); + } + + /** + * Returns the first child of the given object instance, if any. + * + * @param current The object to retrieve the first child of. + * @returns The first child node of the given object, if any. + */ + getFirstChild(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getFirstChild(current); + if (!result) return null; + if (!this.isNavigable(result)) { + return this.getFirstChild(result) || this.getNextSibling(result); + } + return result; + } + + /** + * Returns the parent of the given object instance, if any. + * + * @param current The object to retrieve the parent of. + * @returns The parent node of the given object, if any. + */ + getParent(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getParent(current); + if (!result) return null; + if (!this.isNavigable(result)) return this.getParent(result); + return result; + } + + /** + * Returns the next sibling of the given object instance, if any. + * + * @param current The object to retrieve the next sibling node of. + * @returns The next sibling node of the given object, if any. + */ + getNextSibling(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getNextSibling(current); + if (!result) return null; + if (!this.isNavigable(result)) { + return this.getNextSibling(result); + } + return result; + } + + /** + * Returns the previous sibling of the given object instance, if any. + * + * @param current The object to retrieve the previous sibling node of. + * @returns The previous sibling node of the given object, if any. + */ + getPreviousSibling(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getPreviousSibling(current); + if (!result) return null; + if (!this.isNavigable(result)) { + return this.getPreviousSibling(result); + } + return result; + } + + /** + * Returns the previous node relative to the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The previous node, generally on the "row" visually above the + * specified node, or null if there is none. + */ + getPreviousNode( + node = getFocusManager().getFocusedNode(), + ): IFocusableNode | null { + if (!node) return null; + + let previous = this.getPreviousNodeImpl( + node, + node, + NavigationDirection.PREVIOUS, + ); + + // If the previous node is the root focusable tree or null, we need to + // traverse stacks of top-level items on the tree. Since we're going + // backwards to the previous stack, we actually want the last node in the + // stack (most adjacent to the current node) rather than the root of the + // stack. + if (!previous || (previous as any) === node.getFocusableTree()) { + const stackRoot = this.navigateStacks(node, -1); + if (!stackRoot) return null; + previous = this.getLastNodeInStack(stackRoot, node); + } + + return this.getLeftmostSibling(previous); + } + + /** + * Returns the node to the left of the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The node to the left of the given node, within the same visual + * "row" as the given node, or null if there is none. + */ + getOutNode(node = getFocusManager().getFocusedNode()): IFocusableNode | null { + // Special case: blocks and input value connections on blocks with external + // inputs should always navigate to the parent block, even though they're + // not necessarily on the same visual row. + const connection = + node instanceof BlockSvg + ? node.outputConnection?.targetConnection + : node instanceof RenderedConnection && + node.type === ConnectionType.INPUT_VALUE + ? node + : null; + if ( + connection && + !connection.getSourceBlock().getInputsInline() && + connection !== connection.getSourceBlock().inputList[0].connection + ) { + return connection.getSourceBlock(); + } + + return this.getPreviousNodeImpl(node, node, NavigationDirection.OUT); + } + + /** + * Returns next node relative to the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The next node, generally on the "row" visually below the + * specified node, or null if there is none. + */ + getNextNode( + node = getFocusManager().getFocusedNode(), + ): IFocusableNode | null { + const next = this.getNextNodeImpl(node, node, NavigationDirection.NEXT); + + if (node && next === null) { + return this.navigateStacks(node, 1); + } + + return next; + } + + /** + * Returns the node to the right of the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The node to the right of the given node, within the same visual + * "row" as the given node, or null if there is none. + */ + getInNode(node = getFocusManager().getFocusedNode()): IFocusableNode | null { + return this.getNextNodeImpl(node, node, NavigationDirection.IN); + } + + /** + * Returns the previous sibling/parent node relative to the given node. + * + * @param startNode The node that navigation is starting from. + * @param node The node to navigate relative to. + * @param direction The direction to navigate, either OUT or PREVIOUS. + * @param visitedNodes Set of already-visited nodes used to avoid cycles, + * should not be specified by the caller. + * @returns The previous sibling/parent node, or null if there is none or a + * node was not provided. + */ + private getPreviousNodeImpl( + startNode: IFocusableNode | null, + node: IFocusableNode | null, + direction: NavigationDirection.PREVIOUS | NavigationDirection.OUT, + visitedNodes: Set = new Set(), + ): IFocusableNode | null { + if (!node || !startNode || visitedNodes.has(node)) { + return null; + } + + const newNode = + this.getRightMostChild(this.getPreviousSibling(node), node) || + this.getParent(node); + + if (newNode && this.transitionAllowed(startNode, newNode, direction)) { + return newNode; + } + + if (newNode) { + visitedNodes.add(node); + return this.getPreviousNodeImpl( + startNode, + newNode, + direction, + visitedNodes, + ); + } + return null; + } + + /** + * Returns the next sibling/child node relative to the given node. + * + * @param startNode The node that navigation is starting from. + * @param node The node to navigate relative to. + * @param direction The direction to navigate, either IN or NEXT. + * @param visitedNodes Set of already-visited nodes used to avoid cycles, + * should not be specified by the caller. + * @returns The next sibling/child node, or null if there is none or a + * node was not provided. + */ + private getNextNodeImpl( + startNode: IFocusableNode | null, + node: IFocusableNode | null, + direction: NavigationDirection.NEXT | NavigationDirection.IN, + visitedNodes: Set = new Set(), + ): IFocusableNode | null { + if (!node || !startNode || visitedNodes.has(node)) { + return null; + } + + let newNode = this.getFirstChild(node) || this.getNextSibling(node); + + let target = node; + while (target && !newNode) { + const parent = this.getParent(target); + if (!parent) break; + newNode = this.getNextSibling(parent); + target = parent; + } + + if (newNode && this.transitionAllowed(startNode, newNode, direction)) { + return newNode; + } + if (newNode) { + visitedNodes.add(node); + return this.getNextNodeImpl(startNode, newNode, direction, visitedNodes); + } + + return null; + } + + private getRightMostChild( + node: IFocusableNode | null, + stopIfFound?: IFocusableNode, + ): IFocusableNode | null { + if (!node) return node; + let newNode = this.getFirstChild(node); + if (!newNode || newNode === stopIfFound) return node; + for ( + let nextNode: IFocusableNode | null = newNode; + nextNode; + nextNode = this.getNextSibling(newNode) + ) { + if (nextNode === stopIfFound) break; + newNode = nextNode; + } + return this.getRightMostChild(newNode, stopIfFound); + } + + /** + * Sets whether or not navigation should loop around when reaching the end + * of the workspace. + * + * @param loops True if navigation should loop around, otherwise false. + */ + setNavigationLoops(loops: boolean) { + this.navigationLoops = loops; + } + + /** + * Returns whether or not navigation loops around when reaching the end of + * the workspace. + */ + getNavigationLoops(): boolean { + return this.navigationLoops; + } + + /** + * Get the first navigable node on the workspace, or null if none exist. + * + * @returns The first navigable node on the workspace, or null. + */ + getFirstNode(): IFocusableNode | null { + const root = getFocusManager().getFocusedTree()?.getRootFocusableNode(); + if (!root) return null; + + return this.getFirstChild(root); + } + + /** + * Get the last navigable node on the workspace, or null if none exist. + * + * @returns The last navigable node on the workspace, or null. + */ + getLastNode(): IFocusableNode | null { + const first = this.getFirstNode(); + const oldLooping = this.getNavigationLoops(); + this.setNavigationLoops(true); + const lastNode = this.getPreviousNode(first); + this.setNavigationLoops(oldLooping); + return lastNode; + } + + /** + * Determines whether navigation is allowed between two nodes. + * + * @param current The starting node for proposed navigation. + * @param candidate The proposed destination node. + * @param direction The direction in which the user is navigating. + * @returns True if navigation should be allowed to proceed, or false to find + * a different candidate. + */ + protected transitionAllowed( + current: IFocusableNode, + candidate: IFocusableNode, + direction: NavigationDirection, + ) { + switch (direction) { + case NavigationDirection.IN: + case NavigationDirection.OUT: + return this.getRowId(current) === this.getRowId(candidate); + case NavigationDirection.NEXT: + case NavigationDirection.PREVIOUS: + return this.getRowId(current) !== this.getRowId(candidate); + } + } + + /** + * Returns the leftmost node in the same row as the given node. + * + * @param node The node to find the leftmost sibling of. + * @returns The leftmost sibling of the given node in the same row. + */ + private getLeftmostSibling(node: IFocusableNode | null) { + if (!node) return null; + + let left = node; + let temp; + while ( + (temp = this.getPreviousNodeImpl(left, left, NavigationDirection.OUT)) + ) { + left = temp; + } + + return left; + } + + /** + * Returns the last node in a stack of blocks or other top-level workspace + * entity. + * + * @param stackRoot A top-level item to get the last node of. + * @param stopIfFound A sentinel node that terminates traversal if + * encountered; typically the root node of the next stack. + * @returns The last node in the given stack. + */ + private getLastNodeInStack( + stackRoot: IFocusableNode, + stopIfFound: IFocusableNode, + ) { + let target = stackRoot; + let temp; + while ( + (temp = this.getNextNodeImpl(target, target, NavigationDirection.NEXT)) && + temp !== stopIfFound + ) { + target = temp; + } + + return target; + } + + private getRowId(node: IFocusableNode) { + return this.get(node)?.getRowId(node); + } + + /** + * Returns the next/previous stack relative to the given element's stack. + * + * @param current The element whose stack will be navigated relative to. + * @param delta The difference in index to navigate; positive values navigate + * to the nth next stack, while negative values navigate to the nth + * previous stack. + * @returns The first element in the stack offset by `delta` relative to the + * current element's stack, or the last element in the stack offset by + * `delta` relative to the current element's stack when navigating backwards. + */ + protected navigateStacks(current: IFocusableNode, delta: number) { + const stacks = this.getTopLevelItems(current); + const root = + this.getSourceBlockFromNode(current)?.getRootBlock() ?? current; + const currentIndex = stacks.indexOf(root); + const targetIndex = currentIndex + delta; + let result: IFocusableNode | null = null; + if (targetIndex >= 0 && targetIndex < stacks.length) { + result = stacks[targetIndex]; + } else if (targetIndex < 0 && this.getNavigationLoops()) { + result = stacks[stacks.length - 1]; + } else if (targetIndex >= stacks.length && this.getNavigationLoops()) { + result = stacks[0]; + } + + return result; + } + + /** + * Returns a list of all top-level focusable items on the given node's + * focusable tree. + * + * @param current The node whose root focusable tree to retrieve the top-level + * items of. + * @returns A list of all top-level items on the given node's parent tree. + */ + protected getTopLevelItems(current: IFocusableNode): IFocusableNode[] { + const workspace = current.getFocusableTree(); + return (workspace as any).getTopBoundedElements(true); + } + + /** + * Returns whether or not the given node is navigable. + * + * @param node A focusable node to check the navigability of. + * @returns True if the node is navigable, otherwise false. + */ + protected isNavigable(node: IFocusableNode) { + return this.get(node)?.isNavigable(node); + } + + /** + * Returns the block that the given node is a child of. + * + * @returns The parent block of the node if any, otherwise null. + */ + getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null { + if (node instanceof BlockSvg) { + return node; + } else if (node instanceof Field) { + return node.getSourceBlock() as BlockSvg; + } else if (node instanceof RenderedConnection) { + return node.getSourceBlock(); + } else if (node instanceof Icon) { + return node.getSourceBlock() as BlockSvg; + } + + return null; + } +} diff --git a/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts new file mode 100644 index 00000000000..8b7238cf5b9 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {getFocusManager} from '../../focus_manager.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import {isSelectableToolboxItem} from '../../interfaces/i_selectable_toolbox_item.js'; +import type {IToolbox} from '../../interfaces/i_toolbox.js'; +import {ToolboxItemNavigationPolicy} from '../navigation_policies/toolbox_item_navigation_policy.js'; +import {Navigator} from './navigator.js'; + +/** + * Navigator that handles keyboard navigation within a toolbox. + */ +export class ToolboxNavigator extends Navigator { + constructor(protected toolbox: IToolbox) { + super(); + this.rules = [new ToolboxItemNavigationPolicy()]; + } + + /** + * Returns the flyout's first item when navigating to the right in a toolbox + * from a toolbox item that has a flyout. + */ + override getInNode( + current = getFocusManager().getFocusedNode(), + ): IFocusableNode | null { + if (isSelectableToolboxItem(current) && !current.getContents().length) { + return null; + } + + return ( + this.toolbox.getFlyout()?.getWorkspace().getRestoredFocusableNode(null) ?? + null + ); + } + + /** + * Returns a list of all toolbox items. + */ + protected override getTopLevelItems(): IFocusableNode[] { + return this.toolbox.getToolboxItems(); + } +} diff --git a/packages/blockly/core/marker_manager.ts b/packages/blockly/core/marker_manager.ts deleted file mode 100644 index e94aa3e966a..00000000000 --- a/packages/blockly/core/marker_manager.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Object in charge of managing markers and the cursor. - * - * @class - */ -// Former goog.module ID: Blockly.MarkerManager - -import {LineCursor} from './keyboard_nav/line_cursor.js'; -import type {Marker} from './keyboard_nav/marker.js'; -import type {WorkspaceSvg} from './workspace_svg.js'; - -/** - * Class to manage the multiple markers and the cursor on a workspace. - */ -export class MarkerManager { - /** The name of the local marker. */ - static readonly LOCAL_MARKER = 'local_marker_1'; - - /** The cursor. */ - private cursor: LineCursor; - - /** The map of markers for the workspace. */ - private markers = new Map(); - - /** - * @param workspace The workspace for the marker manager. - * @internal - */ - constructor(private readonly workspace: WorkspaceSvg) { - this.cursor = new LineCursor(this.workspace); - } - - /** - * Register the marker by adding it to the map of markers. - * - * @param id A unique identifier for the marker. - * @param marker The marker to register. - */ - registerMarker(id: string, marker: Marker) { - if (this.markers.has(id)) { - this.unregisterMarker(id); - } - this.markers.set(id, marker); - } - - /** - * Unregister the marker by removing it from the map of markers. - * - * @param id The ID of the marker to unregister. - */ - unregisterMarker(id: string) { - const marker = this.markers.get(id); - if (marker) { - marker.dispose(); - this.markers.delete(id); - } else { - throw Error( - 'Marker with ID ' + - id + - ' does not exist. ' + - 'Can only unregister markers that exist.', - ); - } - } - - /** - * Get the cursor for the workspace. - * - * @returns The cursor for this workspace. - */ - getCursor(): LineCursor { - return this.cursor; - } - - /** - * Get a single marker that corresponds to the given ID. - * - * @param id A unique identifier for the marker. - * @returns The marker that corresponds to the given ID, or null if none - * exists. - */ - getMarker(id: string): Marker | null { - return this.markers.get(id) || null; - } - - /** - * Sets the cursor and initializes the drawer for use with keyboard - * navigation. - * - * @param cursor The cursor used to move around this workspace. - */ - setCursor(cursor: LineCursor) { - this.cursor = cursor; - } - - /** - * Dispose of the marker manager. - * Go through and delete all markers associated with this marker manager. - * - * @internal - */ - dispose() { - const markerIds = Object.keys(this.markers); - for (let i = 0, markerId; (markerId = markerIds[i]); i++) { - this.unregisterMarker(markerId); - } - this.markers.clear(); - this.cursor.dispose(); - } -} diff --git a/packages/blockly/core/navigator.ts b/packages/blockly/core/navigator.ts deleted file mode 100644 index 9c7c22f5987..00000000000 --- a/packages/blockly/core/navigator.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {IFocusableNode} from './interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from './interfaces/i_navigation_policy.js'; -import {BlockCommentNavigationPolicy} from './keyboard_nav/block_comment_navigation_policy.js'; -import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js'; -import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js'; -import {CommentEditorNavigationPolicy} from './keyboard_nav/comment_editor_navigation_policy.js'; -import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js'; -import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js'; -import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js'; -import {WorkspaceCommentNavigationPolicy} from './keyboard_nav/workspace_comment_navigation_policy.js'; -import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js'; - -type RuleList = INavigationPolicy[]; - -/** - * Class responsible for determining where focus should move in response to - * keyboard navigation commands. - */ -export class Navigator { - /** - * Map from classes to a corresponding ruleset to handle navigation from - * instances of that class. - */ - protected rules: RuleList = [ - new BlockNavigationPolicy(), - new FieldNavigationPolicy(), - new ConnectionNavigationPolicy(), - new WorkspaceNavigationPolicy(), - new IconNavigationPolicy(), - new WorkspaceCommentNavigationPolicy(), - new CommentBarButtonNavigationPolicy(), - new BlockCommentNavigationPolicy(), - new CommentEditorNavigationPolicy(), - ]; - - /** - * Adds a navigation ruleset to this Navigator. - * - * @param policy A ruleset that determines where focus should move starting - * from an instance of its managed class. - */ - addNavigationPolicy(policy: INavigationPolicy) { - this.rules.push(policy); - } - - /** - * Returns the navigation ruleset associated with the given object instance's - * class. - * - * @param current An object to retrieve a navigation ruleset for. - * @returns The navigation ruleset of objects of the given object's class, or - * undefined if no ruleset has been registered for the object's class. - */ - private get( - current: IFocusableNode, - ): INavigationPolicy | undefined { - return this.rules.find((rule) => rule.isApplicable(current)); - } - - /** - * Returns the first child of the given object instance, if any. - * - * @param current The object to retrieve the first child of. - * @returns The first child node of the given object, if any. - */ - getFirstChild(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getFirstChild(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) { - return this.getFirstChild(result) || this.getNextSibling(result); - } - return result; - } - - /** - * Returns the parent of the given object instance, if any. - * - * @param current The object to retrieve the parent of. - * @returns The parent node of the given object, if any. - */ - getParent(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getParent(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) return this.getParent(result); - return result; - } - - /** - * Returns the next sibling of the given object instance, if any. - * - * @param current The object to retrieve the next sibling node of. - * @returns The next sibling node of the given object, if any. - */ - getNextSibling(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getNextSibling(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) { - return this.getNextSibling(result); - } - return result; - } - - /** - * Returns the previous sibling of the given object instance, if any. - * - * @param current The object to retrieve the previous sibling node of. - * @returns The previous sibling node of the given object, if any. - */ - getPreviousSibling(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getPreviousSibling(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) { - return this.getPreviousSibling(result); - } - return result; - } -} diff --git a/packages/blockly/core/registry.ts b/packages/blockly/core/registry.ts index 4980a559478..d851d33f2c0 100644 --- a/packages/blockly/core/registry.ts +++ b/packages/blockly/core/registry.ts @@ -26,7 +26,6 @@ import type { IVariableModelStatic, IVariableState, } from './interfaces/i_variable_model.js'; -import type {LineCursor} from './keyboard_nav/line_cursor.js'; import type {Options} from './options.js'; import type {Renderer} from './renderers/common/renderer.js'; import type {Theme} from './theme.js'; @@ -78,8 +77,6 @@ export class Type<_T> { 'connectionPreviewer', ); - static CURSOR = new Type('cursor'); - static EVENT = new Type('event'); static FIELD = new Type('field'); diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 4d5a0c43e0f..c904d04ee8b 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -39,6 +39,7 @@ export enum names { REDO = 'redo', MENU = 'menu', FOCUS_WORKSPACE = 'focus_workspace', + FOCUS_TOOLBOX = 'focus_toolbox', START_MOVE = 'start_move', START_MOVE_STACK = 'start_move_stack', FINISH_MOVE = 'finish_move', @@ -47,6 +48,10 @@ export enum names { MOVE_DOWN = 'move_down', MOVE_LEFT = 'move_left', MOVE_RIGHT = 'move_right', + NAVIGATE_RIGHT = 'right', + NAVIGATE_LEFT = 'left', + NAVIGATE_UP = 'up', + NAVIGATE_DOWN = 'down', DISCONNECT = 'disconnect', } @@ -396,7 +401,11 @@ export function registerMovementShortcuts() { ): IDraggable | undefined => { const node = getFocusManager().getFocusedNode(); if (isDraggable(node)) return node; - return workspace.getCursor().getSourceBlock() ?? undefined; + return ( + workspace + .getNavigator() + .getSourceBlockFromNode(getFocusManager().getFocusedNode()) ?? undefined + ); }; const shiftM = ShortcutRegistry.registry.createSerializedKey(KeyCodes.M, [ @@ -528,7 +537,8 @@ export function registerShowContextMenu() { preconditionFn: (workspace) => { return !workspace.isDragging(); }, - callback: (_workspace, e) => { + callback: (workspace, e) => { + keyboardNavigationController.setIsActive(true); const target = getFocusManager().getFocusedNode(); if (hasContextMenu(target)) { target.showContextMenu(e); @@ -543,6 +553,88 @@ export function registerShowContextMenu() { ShortcutRegistry.registry.register(contextMenuShortcut); } +/** + * Registers keyboard shortcuts to navigate around the Blockly interface. + */ +export function registerArrowNavigation() { + const shortcuts: { + [name: string]: ShortcutRegistry.KeyboardShortcut; + } = { + /** Go to the next location to the right. */ + right: { + name: names.NAVIGATE_RIGHT, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + const node = workspace.RTL + ? getFocusManager().getFocusedTree()?.getNavigator().getOutNode() + : getFocusManager().getFocusedTree()?.getNavigator().getInNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.RIGHT], + allowCollision: true, + }, + + /** Go to the next location to the left. */ + left: { + name: names.NAVIGATE_LEFT, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + const node = workspace.RTL + ? getFocusManager().getFocusedTree()?.getNavigator().getInNode() + : getFocusManager().getFocusedTree()?.getNavigator().getOutNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.LEFT], + allowCollision: true, + }, + + /** Go down to the next location. */ + down: { + name: names.NAVIGATE_DOWN, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: () => { + keyboardNavigationController.setIsActive(true); + const node = getFocusManager() + .getFocusedTree() + ?.getNavigator() + .getNextNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.DOWN], + allowCollision: true, + }, + /** Go up to the previous location. */ + up: { + name: names.NAVIGATE_UP, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: () => { + keyboardNavigationController.setIsActive(true); + const node = getFocusManager() + .getFocusedTree() + ?.getNavigator() + .getPreviousNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.UP], + allowCollision: true, + }, + }; + + for (const shortcut of Object.values(shortcuts)) { + ShortcutRegistry.registry.register(shortcut); + } +} + /** * Registers keyboard shortcut to focus the workspace. */ @@ -557,7 +649,7 @@ export function registerFocusWorkspace() { return workspace.getRootWorkspace() ?? workspace; }; - const contextMenuShortcut: KeyboardShortcut = { + const focusWorkspaceShortcut: KeyboardShortcut = { name: names.FOCUS_WORKSPACE, preconditionFn: (workspace) => !workspace.isDragging(), callback: (workspace) => { @@ -567,7 +659,34 @@ export function registerFocusWorkspace() { }, keyCodes: [KeyCodes.W], }; - ShortcutRegistry.registry.register(contextMenuShortcut); + ShortcutRegistry.registry.register(focusWorkspaceShortcut); +} + +/** + * Registers keyboard shortcut to focus the toolbox. + */ +export function registerFocusToolbox() { + const focusToolboxShortcut: KeyboardShortcut = { + name: names.FOCUS_TOOLBOX, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + const toolbox = workspace.getToolbox(); + if (toolbox) { + keyboardNavigationController.setIsActive(true); + getFocusManager().focusTree(toolbox); + return true; + } else { + const flyout = workspace.getFlyout(); + if (!flyout) return false; + + keyboardNavigationController.setIsActive(true); + getFocusManager().focusTree(flyout.getWorkspace()); + return true; + } + }, + keyCodes: [KeyCodes.T], + }; + ShortcutRegistry.registry.register(focusToolboxShortcut); } /** @@ -621,6 +740,8 @@ export function registerKeyboardNavigationShortcuts() { registerShowContextMenu(); registerMovementShortcuts(); registerFocusWorkspace(); + registerFocusToolbox(); + registerArrowNavigation(); registerDisconnectBlock(); } diff --git a/packages/blockly/core/toolbox/category.ts b/packages/blockly/core/toolbox/category.ts index dd42a549f69..72acdee730f 100644 --- a/packages/blockly/core/toolbox/category.ts +++ b/packages/blockly/core/toolbox/category.ts @@ -593,6 +593,16 @@ export class ToolboxCategory return this.htmlDiv_; } + /** + * Handles this toolbox category gaining focus by informing its parent + * toolbox that it has been selected. + */ + override onNodeFocus(): void { + if (this.getParentToolbox().getSelectedItem() !== this) { + this.getParentToolbox().setSelectedItem(this); + } + } + /** * Gets the contents of the category. These are items that are meant to be * displayed in the flyout. diff --git a/packages/blockly/core/toolbox/separator.ts b/packages/blockly/core/toolbox/separator.ts index cd5ed245a04..bcf66a16b8e 100644 --- a/packages/blockly/core/toolbox/separator.ts +++ b/packages/blockly/core/toolbox/separator.ts @@ -73,6 +73,13 @@ export class ToolboxSeparator extends ToolboxItem { override dispose() { dom.removeNode(this.htmlDiv as HTMLDivElement); } + + /** + * Prevents separator toolbox items from gaining focus. + */ + override canBeFocused(): boolean { + return false; + } } export namespace ToolboxSeparator { diff --git a/packages/blockly/core/toolbox/toolbox.ts b/packages/blockly/core/toolbox/toolbox.ts index 6f4daf4ed71..28861f231f9 100644 --- a/packages/blockly/core/toolbox/toolbox.ts +++ b/packages/blockly/core/toolbox/toolbox.ts @@ -23,7 +23,10 @@ import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import {getFocusManager} from '../focus_manager.js'; import {type IAutoHideable} from '../interfaces/i_autohideable.js'; -import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js'; +import { + isCollapsibleToolboxItem, + type ICollapsibleToolboxItem, +} from '../interfaces/i_collapsible_toolbox_item.js'; import {isDeletable} from '../interfaces/i_deletable.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IFlyout} from '../interfaces/i_flyout.js'; @@ -35,6 +38,7 @@ import {isSelectableToolboxItem} from '../interfaces/i_selectable_toolbox_item.j import type {IStyleable} from '../interfaces/i_styleable.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import type {IToolboxItem} from '../interfaces/i_toolbox_item.js'; +import {ToolboxNavigator} from '../keyboard_nav/navigators/toolbox_navigator.js'; import * as registry from '../registry.js'; import type {KeyboardShortcut} from '../shortcut_registry.js'; import * as Touch from '../touch.js'; @@ -111,6 +115,9 @@ export class Toolbox /** Whether the mouse is currently being clicked. */ private mouseDown = false; + /** Object used by keyboard navigation to move focus in this toolbox. */ + private navigator = new ToolboxNavigator(this); + /** @param workspace The workspace in which to create new blocks. */ constructor(workspace: WorkspaceSvg) { super(); @@ -300,40 +307,17 @@ export class Toolbox protected onKeyDown_(e: KeyboardEvent) { let handled = false; switch (e.key) { - case 'ArrowDown': - handled = this.selectNext(); - break; - case 'ArrowUp': - handled = this.selectPrevious(); - break; case 'ArrowLeft': - handled = this.selectParent(); + handled = this.toggleSelectedItem(false); break; case 'ArrowRight': - handled = this.selectChild(); - break; - case 'Enter': - case ' ': - if (this.selectedItem_ && this.selectedItem_.isCollapsible()) { - const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem; - collapsibleItem.toggleExpanded(); - handled = true; - } - break; - default: - handled = false; + handled = this.toggleSelectedItem(true); break; } - if (!handled && this.selectedItem_) { - // TODO(#6097): Figure out who implements onKeyDown and which interface it - // should be part of. - if ((this.selectedItem_ as any).onKeyDown) { - handled = (this.selectedItem_ as any).onKeyDown(e); - } - } if (handled) { e.preventDefault(); + e.stopPropagation(); } } @@ -976,99 +960,21 @@ export class Toolbox } /** - * Closes the current item if it is expanded, or selects the parent. + * Sets the currently selected item's expansion state, if possible. * - * @returns True if a parent category was selected, false otherwise. + * @param expanded True to expand the item or false to collapse it. + * @returns True if the selected item's expansion state was updated. */ - private selectParent(): boolean { - if (!this.selectedItem_) { - return false; - } - + private toggleSelectedItem(expanded: boolean): boolean { if ( + isCollapsibleToolboxItem(this.selectedItem_) && this.selectedItem_.isCollapsible() && - (this.selectedItem_ as ICollapsibleToolboxItem).isExpanded() - ) { - const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem; - collapsibleItem.toggleExpanded(); - return true; - } else if ( - this.selectedItem_.getParent() && - this.selectedItem_.getParent()!.isSelectable() + this.selectedItem_.isExpanded() !== expanded ) { - this.setSelectedItem(this.selectedItem_.getParent()); + this.selectedItem_.toggleExpanded(); return true; } - return false; - } - - /** - * Selects the first child of the currently selected item, or nothing if the - * toolbox item has no children. - * - * @returns True if a child category was selected, false otherwise. - */ - private selectChild(): boolean { - if (!this.selectedItem_ || !this.selectedItem_.isCollapsible()) { - return false; - } - const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem; - if (!collapsibleItem.isExpanded()) { - collapsibleItem.toggleExpanded(); - return true; - } else { - this.selectNext(); - return true; - } - } - /** - * Selects the next visible toolbox item. - * - * @returns True if a next category was selected, false otherwise. - */ - private selectNext(): boolean { - if (!this.selectedItem_) { - return false; - } - - const items = [...this.contents.values()]; - let nextItemIdx = items.indexOf(this.selectedItem_) + 1; - if (nextItemIdx > -1 && nextItemIdx < items.length) { - let nextItem = items[nextItemIdx]; - while (nextItem && !nextItem.isSelectable()) { - nextItem = items[++nextItemIdx]; - } - if (nextItem && nextItem.isSelectable()) { - this.setSelectedItem(nextItem); - return true; - } - } - return false; - } - - /** - * Selects the previous visible toolbox item. - * - * @returns True if a previous category was selected, false otherwise. - */ - private selectPrevious(): boolean { - if (!this.selectedItem_) { - return false; - } - - const items = [...this.contents.values()]; - let prevItemIdx = items.indexOf(this.selectedItem_) - 1; - if (prevItemIdx > -1 && prevItemIdx < items.length) { - let prevItem = items[prevItemIdx]; - while (prevItem && !prevItem.isSelectable()) { - prevItem = items[--prevItemIdx]; - } - if (prevItem && prevItem.isSelectable()) { - this.setSelectedItem(prevItem); - return true; - } - } return false; } @@ -1167,6 +1073,14 @@ export class Toolbox this.autoHide(false); } } + + /** + * Returns the Navigator instance to use to move between items in this + * toolbox. + */ + getNavigator(): ToolboxNavigator { + return this.navigator; + } } /** CSS for Toolbox. See css.js for use. */ diff --git a/packages/blockly/core/toolbox/toolbox_item.ts b/packages/blockly/core/toolbox/toolbox_item.ts index 9fc5c160ddc..92c3721363a 100644 --- a/packages/blockly/core/toolbox/toolbox_item.ts +++ b/packages/blockly/core/toolbox/toolbox_item.ts @@ -177,5 +177,12 @@ export class ToolboxItem implements IToolboxItem { canBeFocused(): boolean { return true; } + + /** + * Returns the toolbox this toolbox item belongs to. + */ + getParentToolbox(): IToolbox { + return this.parentToolbox_; + } } // nop by default diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index 657a94d463c..e36668ced5b 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -60,12 +60,9 @@ import {hasBubble} from './interfaces/i_has_bubble.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; import type {IToolbox} from './interfaces/i_toolbox.js'; import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; -import type {LineCursor} from './keyboard_nav/line_cursor.js'; -import type {Marker} from './keyboard_nav/marker.js'; +import {Navigator} from './keyboard_nav/navigators/navigator.js'; import {LayerManager} from './layer_manager.js'; -import {MarkerManager} from './marker_manager.js'; import {Msg} from './msg.js'; -import {Navigator} from './navigator.js'; import {Options} from './options.js'; import * as Procedures from './procedures.js'; import * as registry from './registry.js'; @@ -296,7 +293,6 @@ export class WorkspaceSvg private readonly highlightedBlocks: BlockSvg[] = []; private audioManager: WorkspaceAudio; private grid: Grid | null; - private markerManager: MarkerManager; /** * Map from function names to callbacks, for deciding what to do when a @@ -318,9 +314,6 @@ export class WorkspaceSvg /** Cached parent SVG. */ private cachedParentSvg: SVGElement | null = null; - /** True if keyboard accessibility mode is on, false otherwise. */ - keyboardAccessibilityMode = false; - /** The list of top-level bounded elements on the workspace. */ private topBoundedElements: IBoundedElement[] = []; @@ -384,9 +377,6 @@ export class WorkspaceSvg ? new Grid(this.options.gridPattern, options.gridOptions) : null; - /** Manager in charge of markers and cursors. */ - this.markerManager = new MarkerManager(this); - if (Variables && Variables.internalFlyoutCategory) { this.registerToolboxCategoryCallback( Variables.CATEGORY_NAME, @@ -432,15 +422,6 @@ export class WorkspaceSvg this.cachedParentSvgSize = new Size(0, 0); } - /** - * Get the marker manager for this workspace. - * - * @returns The marker manager. - */ - getMarkerManager(): MarkerManager { - return this.markerManager; - } - /** * Gets the metrics manager for this workspace. * @@ -470,27 +451,6 @@ export class WorkspaceSvg return this.componentManager; } - /** - * Get the marker with the given ID. - * - * @param id The ID of the marker. - * @returns The marker with the given ID or null if no marker with the given - * ID exists. - * @internal - */ - getMarker(id: string): Marker | null { - return this.markerManager.getMarker(id); - } - - /** - * The cursor for this workspace. - * - * @returns The cursor for the workspace. - */ - getCursor(): LineCursor { - return this.markerManager.getCursor(); - } - /** * Get the block renderer attached to this workspace. * @@ -834,12 +794,6 @@ export class WorkspaceSvg this.grid.update(this.scale); } this.recordDragTargets(); - const CursorClass = registry.getClassFromOptions( - registry.Type.CURSOR, - this.options, - ); - - if (CursorClass) this.markerManager.setCursor(new CursorClass(this)); const isParentWorkspace = this.options.parentWorkspace === null; this.renderer.createDom( @@ -896,7 +850,6 @@ export class WorkspaceSvg } this.renderer.dispose(); - this.markerManager.dispose(); super.dispose(); diff --git a/packages/blockly/tests/mocha/cursor_test.js b/packages/blockly/tests/mocha/cursor_test.js deleted file mode 100644 index 02426ae26b8..00000000000 --- a/packages/blockly/tests/mocha/cursor_test.js +++ /dev/null @@ -1,922 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {assert} from '../../node_modules/chai/index.js'; -import {createRenderedBlock} from './test_helpers/block_definitions.js'; -import { - sharedTestSetup, - sharedTestTeardown, -} from './test_helpers/setup_teardown.js'; - -suite('Cursor', function () { - suite('Movement', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'input_statement', - 'message0': '%1 %2 %3 %4', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME1', - 'text': 'default', - }, - { - 'type': 'field_input', - 'name': 'NAME2', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'NAME3', - }, - { - 'type': 'input_statement', - 'name': 'NAME4', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'field_input', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - ], - 'output': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'multi_statement_input', - 'message0': '%1 %2', - 'args0': [ - { - 'type': 'input_statement', - 'name': 'FIRST', - }, - { - 'type': 'input_statement', - 'name': 'SECOND', - }, - ], - }, - { - 'type': 'simple_statement', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - const blockA = createRenderedBlock(this.workspace, 'input_statement'); - const blockB = createRenderedBlock(this.workspace, 'input_statement'); - const blockC = createRenderedBlock(this.workspace, 'input_statement'); - const blockD = createRenderedBlock(this.workspace, 'input_statement'); - const blockE = createRenderedBlock(this.workspace, 'field_input'); - - blockA.nextConnection.connect(blockB.previousConnection); - blockA.inputList[0].connection.connect(blockE.outputConnection); - blockB.inputList[1].connection.connect(blockC.previousConnection); - this.cursor.drawer = null; - this.blocks = { - A: blockA, - B: blockB, - C: blockC, - D: blockD, - E: blockE, - }; - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - - test('Next - From a Previous connection go to the next block', function () { - const prevNode = this.blocks.A.previousConnection; - this.cursor.setCurNode(prevNode); - this.cursor.next(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.A); - }); - test('Next - From a block go to its statement input', function () { - const prevNode = this.blocks.B; - this.cursor.setCurNode(prevNode); - this.cursor.next(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.C); - }); - - test('In - From field to attached input connection', function () { - const fieldBlock = this.blocks.E; - const fieldNode = this.blocks.A.getField('NAME2'); - this.cursor.setCurNode(fieldNode); - this.cursor.in(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, fieldBlock); - }); - - test('Prev - From previous connection does skip over next connection', function () { - const prevConnection = this.blocks.B.previousConnection; - const prevConnectionNode = prevConnection; - this.cursor.setCurNode(prevConnectionNode); - this.cursor.prev(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.A); - }); - - test('Prev - From first block loop to last block', function () { - const prevConnection = this.blocks.A; - const prevConnectionNode = prevConnection; - this.cursor.setCurNode(prevConnectionNode); - this.cursor.prev(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.D); - }); - - test('Out - From field does not skip over block node', function () { - const field = this.blocks.E.inputList[0].fieldRow[0]; - const fieldNode = field; - this.cursor.setCurNode(fieldNode); - this.cursor.out(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.E); - }); - - test('Out - From first connection loop to last next connection', function () { - const prevConnection = this.blocks.A.previousConnection; - const prevConnectionNode = prevConnection; - this.cursor.setCurNode(prevConnectionNode); - this.cursor.out(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.D.nextConnection); - }); - }); - - suite('Multiple statement inputs', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'multi_statement_input', - 'message0': '%1 %2', - 'args0': [ - { - 'type': 'input_statement', - 'name': 'FIRST', - }, - { - 'type': 'input_statement', - 'name': 'SECOND', - }, - ], - }, - { - 'type': 'simple_statement', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - - this.multiStatement1 = createRenderedBlock( - this.workspace, - 'multi_statement_input', - ); - this.multiStatement2 = createRenderedBlock( - this.workspace, - 'multi_statement_input', - ); - this.firstStatement = createRenderedBlock( - this.workspace, - 'simple_statement', - ); - this.secondStatement = createRenderedBlock( - this.workspace, - 'simple_statement', - ); - this.thirdStatement = createRenderedBlock( - this.workspace, - 'simple_statement', - ); - this.fourthStatement = createRenderedBlock( - this.workspace, - 'simple_statement', - ); - this.multiStatement1 - .getInput('FIRST') - .connection.connect(this.firstStatement.previousConnection); - this.firstStatement.nextConnection.connect( - this.secondStatement.previousConnection, - ); - this.multiStatement1 - .getInput('SECOND') - .connection.connect(this.thirdStatement.previousConnection); - this.multiStatement2 - .getInput('FIRST') - .connection.connect(this.fourthStatement.previousConnection); - }); - - teardown(function () { - sharedTestTeardown.call(this); - }); - - test('In - from field in nested statement block to next nested statement block', function () { - this.cursor.setCurNode(this.secondStatement.getField('NAME')); - this.cursor.in(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.thirdStatement); - }); - test('In - from field in nested statement block to next stack', function () { - this.cursor.setCurNode(this.thirdStatement.getField('NAME')); - this.cursor.in(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.multiStatement2); - }); - - test('Out - from nested statement block to last field of previous nested statement block', function () { - this.cursor.setCurNode(this.thirdStatement); - this.cursor.out(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.secondStatement.getField('NAME')); - }); - - test('Out - from root block to last field of last nested statement block in previous stack', function () { - this.cursor.setCurNode(this.multiStatement2); - this.cursor.out(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.thirdStatement.getField('NAME')); - }); - }); - - suite('Searching', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'empty_block', - 'message0': '', - }, - { - 'type': 'stack_block', - 'message0': '', - 'previousStatement': null, - 'nextStatement': null, - }, - { - 'type': 'row_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'input_value', - 'name': 'INPUT', - }, - ], - 'output': null, - }, - { - 'type': 'statement_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'input_statement', - 'name': 'STATEMENT', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - { - 'type': 'c_hat_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'input_statement', - 'name': 'STATEMENT', - }, - ], - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - suite('one empty block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('empty_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); - }); - }); - - suite('one stack block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('stack_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); - }); - }); - - suite('one row block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('row_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA.inputList[0].connection); - }); - }); - suite('one c-hat block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('c_hat_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); - }); - }); - - suite('multiblock stack', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'B', - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - const blockA = this.workspace.getBlockById('A'); - assert.equal(node, blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - const blockB = this.workspace.getBlockById('B'); - assert.equal(node, blockB); - }); - }); - - suite('multiblock row', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'row_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'inputs': { - 'INPUT': { - 'block': { - 'type': 'row_block', - 'id': 'B', - }, - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - const blockA = this.workspace.getBlockById('A'); - assert.equal(node, blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - const blockB = this.workspace.getBlockById('B'); - assert.equal(node, blockB.inputList[0].connection); - }); - }); - - suite('two stacks', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'B', - }, - }, - }, - { - 'type': 'stack_block', - 'id': 'C', - 'x': 100, - 'y': 100, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'D', - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - const location = node; - const blockA = this.workspace.getBlockById('A'); - assert.equal(location, blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - const location = node; - const blockD = this.workspace.getBlockById('D'); - assert.equal(location, blockD); - }); - }); - }); - suite('Get next node', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'empty_block', - 'message0': '', - }, - { - 'type': 'stack_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'FIELD', - 'text': 'default', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - { - 'type': 'row_block', - 'message0': '%1 %2', - 'args0': [ - { - 'type': 'field_input', - 'name': 'FIELD', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'INPUT', - }, - ], - 'output': null, - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - this.neverValid = () => false; - this.alwaysValid = () => true; - this.isBlock = (node) => { - return node && node instanceof Blockly.BlockSvg; - }; - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - suite('stack', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'B', - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'C', - }, - }, - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - this.blockA = this.workspace.getBlockById('A'); - this.blockB = this.workspace.getBlockById('B'); - this.blockC = this.workspace.getBlockById('C'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('Never valid - start at top', function () { - const startNode = this.blockA.previousConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(nextNode); - }); - test('Never valid - start in middle', function () { - const startNode = this.blockB; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(nextNode); - }); - test('Never valid - start at end', function () { - const startNode = this.blockC.nextConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(nextNode); - }); - - test('Always valid - start at top', function () { - const startNode = this.blockA.previousConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(nextNode, this.blockA); - }); - test('Always valid - start in middle', function () { - const startNode = this.blockB; - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(nextNode, this.blockB.getField('FIELD')); - }); - test('Always valid - start at end', function () { - const startNode = this.blockC.getField('FIELD'); - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - false, - ); - assert.isNull(nextNode); - }); - - test('Valid if block - start at top', function () { - const startNode = this.blockA; - const nextNode = this.cursor.getNextNode( - startNode, - this.isBlock, - false, - ); - assert.equal(nextNode, this.blockB); - }); - test('Valid if block - start in middle', function () { - const startNode = this.blockB; - const nextNode = this.cursor.getNextNode( - startNode, - this.isBlock, - false, - ); - assert.equal(nextNode, this.blockC); - }); - test('Valid if block - start at end', function () { - const startNode = this.blockC.getField('FIELD'); - const nextNode = this.cursor.getNextNode( - startNode, - this.isBlock, - false, - ); - assert.isNull(nextNode); - }); - test('Never valid - start at end - with loopback', function () { - const startNode = this.blockC.nextConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - true, - ); - assert.isNull(nextNode); - }); - test('Always valid - start at end - with loopback', function () { - const startNode = this.blockC.nextConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - true, - ); - assert.equal(nextNode, this.blockA.previousConnection); - }); - - test('Valid if block - start at end - with loopback', function () { - const startNode = this.blockC; - const nextNode = this.cursor.getNextNode(startNode, this.isBlock, true); - assert.equal(nextNode, this.blockA); - }); - }); - }); - - suite('Get previous node', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'empty_block', - 'message0': '', - }, - { - 'type': 'stack_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'FIELD', - 'text': 'default', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - { - 'type': 'row_block', - 'message0': '%1 %2', - 'args0': [ - { - 'type': 'field_input', - 'name': 'FIELD', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'INPUT', - }, - ], - 'output': null, - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - this.neverValid = () => false; - this.alwaysValid = () => true; - this.isBlock = (node) => { - return node && node instanceof Blockly.BlockSvg; - }; - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - suite('stack', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'B', - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'C', - }, - }, - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - this.blockA = this.workspace.getBlockById('A'); - this.blockB = this.workspace.getBlockById('B'); - this.blockC = this.workspace.getBlockById('C'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('Never valid - start at top', function () { - const startNode = this.blockA.previousConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(previousNode); - }); - test('Never valid - start in middle', function () { - const startNode = this.blockB; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(previousNode); - }); - test('Never valid - start at end', function () { - const startNode = this.blockC.nextConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(previousNode); - }); - - test('Always valid - start at top', function () { - const startNode = this.blockA; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - false, - ); - assert.isNull(previousNode); - }); - test('Always valid - start in middle', function () { - const startNode = this.blockB; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(previousNode, this.blockA.getField('FIELD')); - }); - test('Always valid - start at end', function () { - const startNode = this.blockC.nextConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(previousNode, this.blockC.getField('FIELD')); - }); - - test('Valid if block - start at top', function () { - const startNode = this.blockA; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - false, - ); - assert.isNull(previousNode); - }); - test('Valid if block - start in middle', function () { - const startNode = this.blockB; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - false, - ); - assert.equal(previousNode, this.blockA); - }); - test('Valid if block - start at end', function () { - const startNode = this.blockC; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - false, - ); - assert.equal(previousNode, this.blockB); - }); - test('Never valid - start at top - with loopback', function () { - const startNode = this.blockA.previousConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - true, - ); - assert.isNull(previousNode); - }); - test('Always valid - start at top - with loopback', function () { - const startNode = this.blockA.previousConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - true, - ); - assert.equal(previousNode, this.blockC.nextConnection); - }); - test('Valid if block - start at top - with loopback', function () { - const startNode = this.blockA; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - true, - ); - assert.equal(previousNode, this.blockC); - }); - }); - }); -}); diff --git a/packages/blockly/tests/mocha/field_checkbox_test.js b/packages/blockly/tests/mocha/field_checkbox_test.js index 74357338a5a..c639f35814f 100644 --- a/packages/blockly/tests/mocha/field_checkbox_test.js +++ b/packages/blockly/tests/mocha/field_checkbox_test.js @@ -205,9 +205,6 @@ suite('Checkbox Fields', function () { field.sourceBlock_ = { RTL: false, rendered: true, - workspace: { - keyboardAccessibilityMode: false, - }, queueRender: function () { field.render_(); }, diff --git a/packages/blockly/tests/mocha/field_textinput_test.js b/packages/blockly/tests/mocha/field_textinput_test.js index 7cafd00d948..0af0efbabd8 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -447,6 +447,7 @@ suite('Text Input Fields', function () { this.workspace.getBlockById('right_input_block'); const leftField = this.getFieldFromShadowBlock(leftInputBlock); const rightField = this.getFieldFromShadowBlock(rightInputBlock); + Blockly.getFocusManager().focusNode(leftField); leftField.showEditor(); // This must be called to avoid editor resize logic throwing an error. await Blockly.renderManagement.finishQueuedRenders(); @@ -530,6 +531,7 @@ suite('Text Input Fields', function () { this.workspace.getBlockById('right_input_block'); const leftField = this.getFieldFromShadowBlock(leftInputBlock); const rightField = this.getFieldFromShadowBlock(rightInputBlock); + Blockly.getFocusManager().focusNode(leftField); leftField.showEditor(); // This must be called to avoid editor resize logic throwing an error. await Blockly.renderManagement.finishQueuedRenders(); diff --git a/packages/blockly/tests/mocha/index.html b/packages/blockly/tests/mocha/index.html index 012bfe201ca..4ced6d41886 100644 --- a/packages/blockly/tests/mocha/index.html +++ b/packages/blockly/tests/mocha/index.html @@ -169,7 +169,6 @@ import './connection_test.js'; import './contextmenu_items_test.js'; import './contextmenu_test.js'; - import './cursor_test.js'; import './dialog_test.js'; import './dropdowndiv_test.js'; import './event_test.js'; @@ -221,6 +220,7 @@ import './json_test.js'; import './keyboard_movement_test.js'; import './keyboard_navigation_controller_test.js'; + import './keyboard_navigation_test.js'; import './layering_test.js'; import './blocks/lists_test.js'; import './blocks/logic_ternary_test.js'; @@ -338,9 +338,10 @@ - + + + + diff --git a/packages/blockly/tests/mocha/keyboard_navigation_test.js b/packages/blockly/tests/mocha/keyboard_navigation_test.js new file mode 100644 index 00000000000..e3491dca54a --- /dev/null +++ b/packages/blockly/tests/mocha/keyboard_navigation_test.js @@ -0,0 +1,403 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from '../../build/src/core/blockly.js'; +import {assert} from '../../node_modules/chai/index.js'; +import {navigationTestBlocks} from './test_helpers/navigation_test_blocks.js'; +import {p5blocks} from './test_helpers/p5_blocks.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; +import {createKeyDownEvent} from './test_helpers/user_input.js'; + +/** + * Dispatches a keydown event with the given keycode on the workspace injection + * div. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace to dispatch on. + * @param {number} keyCode The key code to dispatch. + * @param {!Array=} modifiers Optional modifier key codes. + */ +function pressKey(workspace, keyCode, modifiers) { + const event = createKeyDownEvent(keyCode, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); +} + +/** + * Dispatches a keydown event with the given keycode multiple times. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace to dispatch on. + * @param {number} keyCode The key code to dispatch. + * @param {number} times The number of times to press the key. + * @param {!Array=} modifiers Optional modifier key codes. + */ +function pressKeyN(workspace, keyCode, times, modifiers) { + for (let i = 0; i < times; i++) { + pressKey(workspace, keyCode, modifiers); + } +} + +/** + * Focuses the block with the given ID on the given workspace. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace containing the block. + * @param {string} blockId The ID of the block to focus. + */ +function focusBlock(workspace, blockId) { + const block = workspace.getBlockById(blockId); + if (!block) throw new Error(`No block found with ID: ${blockId}`); + Blockly.getFocusManager().focusNode(block); +} + +/** + * Focuses the named field on a block. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace containing the block. + * @param {string} blockId The ID of the block. + * @param {string} fieldName The name of the field to focus. + */ +function focusBlockField(workspace, blockId, fieldName) { + const block = workspace.getBlockById(blockId); + if (!block) throw new Error(`No block found with ID: ${blockId}`); + const field = block.getField(fieldName); + if (!field) { + throw new Error(`No field found: ${fieldName} (block ${blockId})`); + } + Blockly.getFocusManager().focusNode(field); +} + +/** + * Returns the block ID of the currently focused node, or undefined if the + * focused node is not a block. + * + * @returns {string|undefined} ID of the focused block, if any. + */ +function getFocusedBlockId() { + const node = Blockly.getFocusManager().getFocusedNode(); + if (node instanceof Blockly.BlockSvg) return node.id; + return undefined; +} + +/** + * Returns the DOM element ID of the currently focused node's focusable element. + * + * @returns {string|undefined} ID of the focused node, if any. + */ +function getFocusNodeId() { + return Blockly.getFocusManager().getFocusedNode()?.getFocusableElement()?.id; +} + +/** + * Returns the name of the currently focused field, or undefined if the focused + * node is not a field. + * + * @returns {string|undefined} Name of the focused field, if any. + */ +function getFocusedFieldName() { + return Blockly.getFocusManager().getFocusedNode()?.name; +} + +/** + * Returns the block type of the currently focused node, or undefined if the + * focused node is not a block. + * + * @returns {string|undefined} Type of the focused block, if any. + */ +function getFocusedBlockType() { + const node = Blockly.getFocusManager().getFocusedNode(); + if (node instanceof Blockly.BlockSvg) return node.type; + return undefined; +} + +/** + * Focuses the workspace comment with the given ID. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace containing the comment. + * @param {string} commentId The ID of the workspace comment to focus. + */ +function focusWorkspaceComment(workspace, commentId) { + const comment = workspace.getCommentById(commentId); + if (!comment) { + throw new Error(`No workspace comment found with ID: ${commentId}`); + } + Blockly.getFocusManager().focusNode(comment); +} + +suite('Keyboard navigation on Blocks', function () { + setup(async function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: 'zelos', + }); + Blockly.common.defineBlocks(p5blocks); + Blockly.serialization.workspaces.load(navigationTestBlocks, this.workspace); + for (const block of this.workspace.getAllBlocks()) { + block.initSvg(); + block.render(); + } + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Default workspace', function () { + const blockCount = this.workspace.getAllBlocks(false).length; + assert.equal(blockCount, 16); + }); + + test('Selected block', function () { + Blockly.getFocusManager().focusTree(this.workspace); + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 13); + assert.equal(getFocusedBlockId(), 'controls_repeat_ext_1'); + }); + + test('Down from statement block selects next block across stacks', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + // The first down moves to the next connection on the selected block. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusedBlockId(), 'p5_draw_1'); + }); + + test('Up from statement block selects previous block', function () { + focusBlock(this.workspace, 'simple_circle_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'draw_emoji_1'); + }); + + test('Down from parent block selects first child block', function () { + focusBlock(this.workspace, 'p5_setup_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'p5_canvas_1'); + }); + + test('Up from child block selects parent block', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'p5_setup_1'); + }); + + test('Right from block selects first field', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(getFocusNodeId(), 'p5_canvas_1_field_'); + assert.equal(getFocusedFieldName(), 'WIDTH'); + }); + + test('Right from block selects first inline input', function () { + focusBlock(this.workspace, 'simple_circle_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'colour_picker_1'); + }); + + test('Up from inline input selects statement block', function () { + focusBlock(this.workspace, 'math_number_2'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getBlockById('simple_circle_1').nextConnection, + ); + }); + + test('Left from first inline input selects block', function () { + focusBlock(this.workspace, 'math_number_2'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'math_modulo_1'); + }); + + test('Right from first inline input selects second inline input', function () { + focusBlock(this.workspace, 'math_number_2'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'math_number_3'); + }); + + test('Left from second inline input selects first inline input', function () { + focusBlock(this.workspace, 'math_number_3'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'math_number_2'); + }); + + test('Right from last inline input block selects next child field', function () { + focusBlock(this.workspace, 'colour_picker_1'); + // Go right twice; should not wrap to next row. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.RIGHT, 2); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getBlockById('colour_picker_1').getField('TEXT'), + ); + }); + + test('Down from inline input selects next block', function () { + focusBlock(this.workspace, 'colour_picker_1'); + // Go down twice; first one selects the next connection on the colour + // picker's parent block. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusedBlockId(), 'controls_repeat_ext_1'); + }); + + test("Down from inline input selects block's child block", function () { + focusBlock(this.workspace, 'logic_boolean_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'text_print_1'); + }); + + test('Right from text block selects shadow block then field', function () { + focusBlock(this.workspace, 'text_print_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'text_1'); + + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(getFocusNodeId(), 'text_1_field_'); + }); +}); + +suite('Keyboard navigation on Fields', function () { + setup(function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: 'zelos', + }); + Blockly.common.defineBlocks(p5blocks); + Blockly.serialization.workspaces.load(navigationTestBlocks, this.workspace); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Up from first field selects previous block', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'p5_setup_1'); + }); + + test('Left from first field selects block', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'p5_canvas_1'); + }); + + test('Right from first field selects second field', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(getFocusNodeId(), 'p5_canvas_1_field_'); + assert.equal(getFocusedFieldName(), 'HEIGHT'); + }); + + test('Left from second field selects first field', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'HEIGHT'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.include(getFocusNodeId(), 'p5_canvas_1_field_'); + assert.equal(getFocusedFieldName(), 'WIDTH'); + }); + + test('Right from second field selects does not change focus', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'HEIGHT'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getBlockById('p5_canvas_1').getField('HEIGHT'), + ); + }); + + test('Down from field selects next block', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + // Go down twice; first one selects the next connection on the create + // canvas block. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusedBlockId(), 'p5_draw_1'); + }); + + test("Down from field selects block's child block", function () { + focusBlockField(this.workspace, 'controls_repeat_1', 'TIMES'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'draw_emoji_1'); + }); +}); + +suite('Workspace comment navigation', function () { + setup(async function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: 'zelos', + }); + Blockly.common.defineBlocks(p5blocks); + Blockly.serialization.workspaces.load(navigationTestBlocks, this.workspace); + this.workspace.getTopBlocks(false).forEach((b) => b.queueRender()); + Blockly.renderManagement.triggerQueuedRenders(this.workspace); + + const comment1 = Blockly.serialization.workspaceComments.append( + {text: 'Comment one', x: 200, y: 200}, + this.workspace, + ); + const comment2 = Blockly.serialization.workspaceComments.append( + {text: 'Comment two', x: 300, y: 300}, + this.workspace, + ); + this.commentId1 = comment1.id; + this.commentId2 = comment2.id; + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Navigate forward from block to workspace comment', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusNodeId(), this.commentId1); + }); + + test('Navigate forward from workspace comment to block', function () { + focusWorkspaceComment(this.workspace, this.commentId2); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockType(), 'p5_draw'); + }); + + test('Navigate backward from block to workspace comment', function () { + focusBlock(this.workspace, 'p5_draw_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusNodeId(), this.commentId2); + }); + + test('Navigate backward from workspace comment to block', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKeyN(this.workspace, Blockly.utils.KeyCodes.UP, 2); + assert.equal(getFocusedBlockType(), 'p5_canvas'); + }); + + test('Navigate forward from workspace comment to workspace comment', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusNodeId(), this.commentId2); + }); + + test('Navigate backward from workspace comment to workspace comment', function () { + focusWorkspaceComment(this.workspace, this.commentId2); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusNodeId(), this.commentId1); + }); + + test('Navigate forward from workspace comment to workspace comment button', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusNodeId(), `${this.commentId1}_collapse_bar_button`); + }); + + test('Navigate backward from workspace comment button to workspace comment', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusNodeId(), this.commentId1); + }); +}); diff --git a/packages/blockly/tests/mocha/navigation_test.js b/packages/blockly/tests/mocha/navigation_test.js index 2aad16986dc..3b9660a4c16 100644 --- a/packages/blockly/tests/mocha/navigation_test.js +++ b/packages/blockly/tests/mocha/navigation_test.js @@ -21,21 +21,21 @@ suite('Navigation', function () { 'args0': [ { 'type': 'field_input', - 'name': 'NAME', + 'name': 'NAME1', 'text': 'default', }, { 'type': 'field_input', - 'name': 'NAME', + 'name': 'NAME2', 'text': 'default', }, { 'type': 'input_value', - 'name': 'NAME', + 'name': 'NAME3', }, { 'type': 'input_statement', - 'name': 'NAME', + 'name': 'NAME4', }, ], 'previousStatement': null, @@ -444,7 +444,7 @@ suite('Navigation', function () { const nextConnection = this.blocks.statementInput1.nextConnection; const prevConnection = this.blocks.statementInput2.previousConnection; const nextNode = this.navigator.getNextSibling(nextConnection); - assert.equal(nextNode, prevConnection); + assert.equal(nextNode, prevConnection.getSourceBlock()); }); test('fromInputToInput', function () { const input = this.blocks.doubleValueInput.inputList[0]; @@ -471,8 +471,6 @@ suite('Navigation', function () { }); test('fromFieldToNestedBlock', function () { const field = this.blocks.statementInput1.inputList[0].fieldRow[1]; - const inputConnection = - this.blocks.statementInput1.inputList[0].connection; const nextNode = this.navigator.getNextSibling(field); assert.equal(nextNode, this.blocks.fieldWithOutput); }); @@ -531,13 +529,6 @@ suite('Navigation', function () { ); assert.equal(nextNode, field); }); - test('fromBlockToFieldSkippingInput', function () { - const field = this.blocks.buttonBlock.getField('BUTTON3'); - const nextNode = this.navigator.getNextSibling( - this.blocks.buttonInput2, - ); - assert.equal(nextNode, field); - }); test('skipsChildrenOfCollapsedBlocks', function () { this.blocks.buttonBlock.setCollapsed(true); const nextNode = this.navigator.getNextSibling(this.blocks.buttonBlock); @@ -545,6 +536,7 @@ suite('Navigation', function () { }); test('fromFieldSkipsHiddenInputs', function () { this.blocks.buttonBlock.inputList[2].setVisible(false); + this.blocks.buttonBlock.inputList[3].setVisible(false); const fieldStart = this.blocks.buttonBlock.getField('BUTTON2'); const fieldEnd = this.blocks.buttonBlock.getField('BUTTON3'); const nextNode = this.navigator.getNextSibling(fieldStart); @@ -553,16 +545,19 @@ suite('Navigation', function () { }); suite('Previous', function () { - test('fromPreviousToNext', function () { + test('fromPreviousToPriorBlock', function () { const prevConnection = this.blocks.statementInput2.previousConnection; const prevNode = this.navigator.getPreviousSibling(prevConnection); const nextConnection = this.blocks.statementInput1.nextConnection; - assert.equal(prevNode, nextConnection); + assert.equal(prevNode, nextConnection.getSourceBlock()); }); test('fromPreviousToInput', function () { const prevConnection = this.blocks.statementInput3.previousConnection; const prevNode = this.navigator.getPreviousSibling(prevConnection); - assert.isNull(prevNode); + assert.equal( + prevNode, + this.blocks.statementInput2.inputList[0].connection, + ); }); test('fromBlockToPrevious', function () { const prevNode = this.navigator.getPreviousSibling( @@ -575,7 +570,6 @@ suite('Navigation', function () { const prevNode = this.navigator.getPreviousSibling( this.blocks.fieldWithOutput, ); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; assert.equal(prevNode, [...this.blocks.statementInput1.getFields()][1]); }); test('fromNextToBlock', function () { @@ -605,10 +599,10 @@ suite('Navigation', function () { const prevNode = this.navigator.getPreviousSibling(input.connection); assert.equal(prevNode, inputConnection); }); - test('fromOutputToNull', function () { + test('fromOutputToField', function () { const output = this.blocks.fieldWithOutput.outputConnection; const prevNode = this.navigator.getPreviousSibling(output); - assert.isNull(prevNode); + assert.equal(this.blocks.statementInput1.getField('NAME2'), prevNode); }); test('fromFieldToNull', function () { const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; @@ -622,8 +616,6 @@ suite('Navigation', function () { ); const field = this.blocks.fieldAndInputs2.inputList[1].fieldRow[0]; - const inputConnection = - this.blocks.fieldAndInputs2.inputList[0].connection; const prevNode = this.navigator.getPreviousSibling(field); assert.equal(prevNode, outputBlock); }); @@ -692,6 +684,7 @@ suite('Navigation', function () { }); test('fromFieldSkipsHiddenInputs', function () { this.blocks.buttonBlock.inputList[2].setVisible(false); + this.blocks.buttonBlock.inputList[3].setVisible(false); const fieldStart = this.blocks.buttonBlock.getField('BUTTON3'); const fieldEnd = this.blocks.buttonBlock.getField('BUTTON2'); const nextNode = this.navigator.getPreviousSibling(fieldStart); @@ -709,24 +702,11 @@ suite('Navigation', function () { workspaceTeardown.call(this, this.emptyWorkspace); }); - test('fromInputToOutput', function () { - const input = this.blocks.statementInput1.inputList[0]; - const inNode = this.navigator.getFirstChild(input.connection); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; - assert.equal(inNode, outputConnection); - }); test('fromInputToNull', function () { const input = this.blocks.statementInput2.inputList[0]; const inNode = this.navigator.getFirstChild(input.connection); assert.isNull(inNode); }); - test('fromInputToPrevious', function () { - const input = this.blocks.statementInput2.inputList[1]; - const previousConnection = - this.blocks.statementInput3.previousConnection; - const inNode = this.navigator.getFirstChild(input.connection); - assert.equal(inNode, previousConnection); - }); test('fromBlockToInput', function () { const connection = this.blocks.valueInput.inputList[0].connection; const inNode = this.navigator.getFirstChild(this.blocks.valueInput); diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 6edef8c3e71..44e1e42bf62 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -552,6 +552,56 @@ suite('Keyboard Shortcut Items', function () { }); }); + suite('Focus Toolbox (T)', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'basic_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'TEXT', + 'text': 'default', + }, + ], + }, + ]); + }); + + test('Does not change focus when toolbox item is already focused', function () { + const item = this.workspace.getToolbox().getToolboxItems()[1]; + Blockly.getFocusManager().focusNode(item); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.T); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), item); + }); + + test('Focuses toolbox when workspace is focused', function () { + Blockly.getFocusManager().focusTree(this.workspace); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.T); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.workspace.getToolbox(), + ); + }); + + test('Focuses mutator flyout when mutator workspace is focused', async function () { + const block = this.workspace.newBlock('controls_if'); + const icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE); + await icon.setBubbleVisible(true); + const mutatorWorkspace = icon.getWorkspace(); + Blockly.getFocusManager().focusTree(mutatorWorkspace); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.T); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + mutatorWorkspace.getFlyout().getWorkspace(), + ); + }); + }); + suite('Disconnect Block (X)', function () { setup(function () { this.blockA = this.workspace.newBlock('stack_block'); diff --git a/packages/blockly/tests/mocha/shortcut_registry_test.js b/packages/blockly/tests/mocha/shortcut_registry_test.js index a06f01b9c00..a7a7c4d8172 100644 --- a/packages/blockly/tests/mocha/shortcut_registry_test.js +++ b/packages/blockly/tests/mocha/shortcut_registry_test.js @@ -21,6 +21,9 @@ suite('Keyboard Shortcut Registry Test', function () { }); teardown(function () { sharedTestTeardown.call(this); + this.registry.reset(); + Blockly.ShortcutItems.registerDefaultShortcuts(); + Blockly.ShortcutItems.registerKeyboardNavigationShortcuts(); }); suite('Registering', function () { @@ -528,6 +531,4 @@ suite('Keyboard Shortcut Registry Test', function () { assert.throws(shouldThrow, Error, 's is not a valid modifier key.'); }); }); - - teardown(function () {}); }); diff --git a/packages/blockly/tests/mocha/test_helpers/navigation_test_blocks.js b/packages/blockly/tests/mocha/test_helpers/navigation_test_blocks.js new file mode 100644 index 00000000000..a6e062b2092 --- /dev/null +++ b/packages/blockly/tests/mocha/test_helpers/navigation_test_blocks.js @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Workspace state for keyboard navigation tests. Contains: + * - p5_setup with a p5_canvas child + * - p5_draw with a nested stack: controls_if → controls_if (with + * logic_boolean input and text_print child) → controls_repeat (with + * draw_emoji and simple_circle children) → controls_repeat_ext (with a + * math_modulo expression in its TIMES input) + * + * Block IDs are stable so tests can reference them by ID. + */ +export const navigationTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup_1', + 'x': 0, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas_1', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw_1', + 'x': 0, + 'y': 332, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'controls_if', + 'id': 'controls_if_1', + 'next': { + 'block': { + 'type': 'controls_if', + 'id': 'controls_if_2', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_boolean', + 'id': 'logic_boolean_1', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + 'DO0': { + 'block': { + 'type': 'text_print', + 'id': 'text_print_1', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'text_1', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat', + 'id': 'controls_repeat_1', + 'fields': { + 'TIMES': 10, + }, + 'inputs': { + 'DO': { + 'block': { + 'type': 'draw_emoji', + 'id': 'draw_emoji_1', + 'fields': { + 'emoji': '❤️', + }, + 'next': { + 'block': { + 'type': 'simple_circle', + 'id': 'simple_circle_1', + 'inputs': { + 'COLOR': { + 'shadow': { + 'type': 'text', + 'id': 'colour_picker_1', + 'fields': { + 'TEXT': '#ff0000', + }, + }, + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_ext_1', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_1', + 'fields': { + 'NUM': 10, + }, + }, + 'block': { + 'type': 'math_modulo', + 'id': 'math_modulo_1', + 'inputs': { + 'DIVIDEND': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_2', + 'fields': { + 'NUM': 64, + }, + }, + }, + 'DIVISOR': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_3', + 'fields': { + 'NUM': 10, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, +}; diff --git a/packages/blockly/tests/mocha/toolbox_test.js b/packages/blockly/tests/mocha/toolbox_test.js index 480fdfdc6fc..4b1af142734 100644 --- a/packages/blockly/tests/mocha/toolbox_test.js +++ b/packages/blockly/tests/mocha/toolbox_test.js @@ -13,7 +13,6 @@ import { import { getBasicToolbox, getCategoryJSON, - getChildItem, getCollapsibleItem, getDeeplyNestedJSON, getInjectedToolbox, @@ -26,21 +25,16 @@ import { suite('Toolbox', function () { setup(function () { sharedTestSetup.call(this); + this.toolbox = getInjectedToolbox(); defineStackBlock(); }); teardown(function () { + this.toolbox.dispose(); sharedTestTeardown.call(this); }); suite('init', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - test('Init called -> HtmlDiv is created', function () { assert.isDefined(this.toolbox.HtmlDiv); }); @@ -87,12 +81,6 @@ suite('Toolbox', function () { }); suite('render', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); test('Render called with valid toolboxDef -> Contents are created', function () { const positionStub = sinon.stub(this.toolbox, 'position'); this.toolbox.render({ @@ -184,13 +172,6 @@ suite('Toolbox', function () { }); suite('focus management', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - test('Losing focus hides autoclosing flyout', function () { // Focus the toolbox and select a category to open the flyout. const target = this.toolbox.HtmlDiv.querySelector( @@ -235,13 +216,6 @@ suite('Toolbox', function () { }); suite('onClick_', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - test('Toolbox clicked -> Should close flyout', function () { const hideChaffStub = sinon.stub( Blockly.WorkspaceSvg.prototype, @@ -267,220 +241,251 @@ suite('Toolbox', function () { }); }); - suite('onKeyDown_', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - - function createKeyDownMock(key) { - return { - 'key': key, - 'preventDefault': function () {}, - }; - } - - function testCorrectFunctionCalled(toolbox, key, funcName) { - const event = createKeyDownMock(key); - const preventDefaultEvent = sinon.stub(event, 'preventDefault'); - const selectMethodStub = sinon.stub(toolbox, funcName); - selectMethodStub.returns(true); - toolbox.onKeyDown_(event); - sinon.assert.called(selectMethodStub); - sinon.assert.called(preventDefaultEvent); - } - - test('Down button is pushed -> Should call selectNext', function () { - testCorrectFunctionCalled(this.toolbox, 'ArrowDown', 'selectNext', true); - }); - test('Up button is pushed -> Should call selectPrevious', function () { - testCorrectFunctionCalled( - this.toolbox, - 'ArrowUp', - 'selectPrevious', - true, - ); - }); - test('Left button is pushed -> Should call selectParent', function () { - testCorrectFunctionCalled( - this.toolbox, - 'ArrowLeft', - 'selectParent', - true, - ); - }); - test('Right button is pushed -> Should call selectChild', function () { - testCorrectFunctionCalled( - this.toolbox, - 'ArrowRight', - 'selectChild', - true, - ); - }); - test('Enter button is pushed -> Should toggle expanded', function () { - this.toolbox.selectedItem_ = getCollapsibleItem(this.toolbox); - const toggleExpandedStub = sinon.stub( - this.toolbox.selectedItem_, - 'toggleExpanded', - ); - const event = createKeyDownMock('Enter'); - const preventDefaultEvent = sinon.stub(event, 'preventDefault'); - this.toolbox.onKeyDown_(event); - sinon.assert.called(toggleExpandedStub); - sinon.assert.called(preventDefaultEvent); - }); - test('Enter button is pushed when no item is selected -> Should not call prevent default', function () { - this.toolbox.selectedItem_ = null; - const event = createKeyDownMock('Enter'); - const preventDefaultEvent = sinon.stub(event, 'preventDefault'); - this.toolbox.onKeyDown_(event); - sinon.assert.notCalled(preventDefaultEvent); - }); - }); - - suite('Select Methods', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); + suite('on key down', function () { + test('Down arrow should select next item', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex + 1, newIndex); }); - suite('selectChild', function () { - test('No item is selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectChild(); - assert.isFalse(handled); + test('Down arrow should skip separators', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[1]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Item at index 2 is a separator, new index should have incremented by + // 2 instead of 1 to bypass it. + assert.equal(oldIndex + 2, newIndex); + }); + + test('Down arrow should skip children of collapsed item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + assert.isFalse(collapsibleItem.isExpanded()); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, }); - test('Selected item is not collapsible -> Should not handle event', function () { - this.toolbox.selectedItem_ = getNonCollapsibleItem(this.toolbox); - const handled = this.toolbox.selectChild(); - assert.isFalse(handled); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is not expanded, so down should skip its child item + // and advance to the next regular item. + assert.equal(oldIndex + 2, newIndex); + }); + + test('Down arrow should go to first child of expanded item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + collapsibleItem.setExpanded(true); + assert.isTrue(collapsibleItem.isExpanded()); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, }); - test('Selected item is collapsible -> Should expand', function () { - const collapsibleItem = getCollapsibleItem(this.toolbox); - this.toolbox.selectedItem_ = collapsibleItem; - const handled = this.toolbox.selectChild(); - assert.isTrue(handled); - assert.isTrue(collapsibleItem.isExpanded()); - assert.equal(this.toolbox.selectedItem_, collapsibleItem); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is expanded, so down should focus its child item. + assert.equal(oldIndex + 1, newIndex); + }); + + test('Down arrow on last item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[6]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex, newIndex); + }); - test('Selected item is expanded -> Should select child', function () { - const collapsibleItem = getCollapsibleItem(this.toolbox); - collapsibleItem.expanded_ = true; - const selectNextStub = sinon.stub(this.toolbox, 'selectNext'); - this.toolbox.selectedItem_ = collapsibleItem; - const handled = this.toolbox.selectChild(); - assert.isTrue(handled); - sinon.assert.called(selectNextStub); + test('Up arrow should select previous item', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[1]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex - 1, newIndex); }); - suite('selectParent', function () { - test('No item selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectParent(); - assert.isFalse(handled); + test('Up arrow should skip separators', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[3]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Item at index 2 is a separator, new index should have decremented by + // 2 instead of 1 to bypass it. + assert.equal(oldIndex - 2, newIndex); + }); + + test('Up arrow should skip children of collapsed item', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[6]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, }); - test('Selected item is expanded -> Should collapse', function () { - const collapsibleItem = getCollapsibleItem(this.toolbox); - collapsibleItem.expanded_ = true; - this.toolbox.selectedItem_ = collapsibleItem; - const handled = this.toolbox.selectParent(); - assert.isTrue(handled); - assert.isFalse(collapsibleItem.isExpanded()); - assert.equal(this.toolbox.selectedItem_, collapsibleItem); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is not expanded, so up should skip its child item + // and advance to it directly. + assert.equal(oldIndex - 2, newIndex); + }); + + test('Up arrow should go to parent from child item', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[5]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, }); - test('Selected item is not expanded -> Should get parent', function () { - const childItem = getChildItem(this.toolbox); - this.toolbox.selectedItem_ = childItem; - const handled = this.toolbox.selectParent(); - assert.isTrue(handled); - assert.equal(this.toolbox.selectedItem_, childItem.getParent()); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is expanded, so up from its child should go to it. + assert.equal(oldIndex - 1, newIndex); + }); + + test('Up arrow on first item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex, newIndex); + }); + + test('Left arrow should collapse expanded item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + collapsibleItem.setExpanded(true); + assert.isTrue(collapsibleItem.isExpanded()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', + }); + this.toolbox.contentsDiv_.dispatchEvent(event); + assert.isFalse(collapsibleItem.isExpanded()); }); - suite('selectNext', function () { - test('No item is selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectNext(); - assert.isFalse(handled); + test('Left arrow from normal item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', }); - test('Next item is selectable -> Should select next item', function () { - const items = [...this.toolbox.contents.values()]; - const item = items[0]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectNext(); - assert.isTrue(handled); - assert.equal(this.toolbox.selectedItem_, items[1]); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), items[0]); + }); + + test('Left arrow from collapsed item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[4]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', }); - test('Selected item is last item -> Should not handle event', function () { - const items = [...this.toolbox.contents.values()]; - const item = items.at(-1); - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectNext(); - assert.isFalse(handled); - assert.equal(this.toolbox.selectedItem_, item); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), items[4]); + }); + + test('Left arrow from child item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[5]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', }); - test('Selected item is collapsed -> Should skip over its children', function () { - const item = getCollapsibleItem(this.toolbox); - const childItem = item.flyoutItems_[0]; - item.expanded_ = false; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectNext(); - assert.isTrue(handled); - assert.notEqual(this.toolbox.selectedItem_, childItem); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), items[5]); + }); + + test('Right arrow should expand collapsed item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + assert.isFalse(collapsibleItem.isExpanded()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', }); + this.toolbox.contentsDiv_.dispatchEvent(event); + assert.isTrue(collapsibleItem.isExpanded()); }); - suite('selectPrevious', function () { - test('No item is selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectPrevious(); - assert.isFalse(handled); - }); - test('Selected item is first item -> Should not handle event', function () { - const item = [...this.toolbox.contents.values()][0]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectPrevious(); - assert.isFalse(handled); - assert.equal(this.toolbox.selectedItem_, item); + test('Right arrow from normal item should focus flyout', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', }); - test('Previous item is selectable -> Should select previous item', function () { - const items = [...this.toolbox.contents.values()]; - const item = items[1]; - const prevItem = items[0]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectPrevious(); - assert.isTrue(handled); - assert.equal(this.toolbox.selectedItem_, prevItem); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.toolbox.getFlyout().getWorkspace(), + ); + }); + + test('Right arrow from expanded item should focus flyout', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[4]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', }); - test('Previous item is collapsed -> Should skip over children of the previous item', function () { - const childItem = getChildItem(this.toolbox); - const parentItem = childItem.getParent(); - const items = [...this.toolbox.contents.values()]; - const parentIdx = items.indexOf(parentItem); - // Gets the item after the parent. - const item = items[parentIdx + 1]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectPrevious(); - assert.isTrue(handled); - assert.notEqual(this.toolbox.selectedItem_, childItem); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.toolbox.getFlyout().getWorkspace(), + ); + }); + + test('Right arrow from child item should focus flyout', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[5]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.toolbox.getFlyout().getWorkspace(), + ); }); }); suite('setSelectedItem', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - function setupSetSelected(toolbox, oldItem, newItem) { toolbox.selectedItem_ = oldItem; const newItemStub = sinon.stub(newItem, 'setSelected'); @@ -526,13 +531,6 @@ suite('Toolbox', function () { }); suite('updateFlyout_', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - function testHideFlyout(toolbox, oldItem, newItem) { const updateFlyoutStub = sinon.stub(toolbox.getFlyout(), 'hide'); toolbox.updateFlyout_(oldItem, newItem); @@ -778,12 +776,6 @@ suite('Toolbox', function () { }); }); suite('Nested Categories', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); test('Child categories visible if all ancestors expanded', function () { this.toolbox.render(getDeeplyNestedJSON()); const items = [...this.toolbox.contents.values()];