From 29dabb331c2c9df423853a7af829953b9889849d Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 18 Aug 2025 14:00:33 -0700 Subject: [PATCH] feat: Lay the foundation for keyboard navigation into flyout items. --- core/blockly.ts | 3 +- core/keyboard_nav/flyout_navigation_policy.ts | 12 +-- core/keyboard_nav/line_cursor.ts | 83 +++++++++++++++---- .../workspace_navigation_policy.ts | 10 +++ 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/core/blockly.ts b/core/blockly.ts index 99112d790..e0c3b1a7d 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -171,7 +171,7 @@ 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 {LineCursor, NavigationDirection} from './keyboard_nav/line_cursor.js'; import {Marker} from './keyboard_nav/marker.js'; import { KeyboardNavigationController, @@ -466,6 +466,7 @@ export { Events, Extensions, LineCursor, + NavigationDirection, Procedures, ShortcutItems, Themes, diff --git a/core/keyboard_nav/flyout_navigation_policy.ts b/core/keyboard_nav/flyout_navigation_policy.ts index 6552c27b4..fdc22d849 100644 --- a/core/keyboard_nav/flyout_navigation_policy.ts +++ b/core/keyboard_nav/flyout_navigation_policy.ts @@ -29,8 +29,8 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { * @param _current The flyout item to navigate from. * @returns Null to prevent navigating into flyout items. */ - getFirstChild(_current: T): IFocusableNode | null { - return null; + getFirstChild(current: T): IFocusableNode | null { + return this.policy.getFirstChild(current); } /** @@ -57,10 +57,10 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { (flyoutItem) => flyoutItem.getElement() === current, ); - if (index === -1) return null; + if (index === -1) return this.policy.getNextSibling(current); index++; if (index >= flyoutContents.length) { - index = 0; + return null; } return flyoutContents[index].getElement(); @@ -80,10 +80,10 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { (flyoutItem) => flyoutItem.getElement() === current, ); - if (index === -1) return null; + if (index === -1) return this.policy.getPreviousSibling(current); index--; if (index < 0) { - index = flyoutContents.length - 1; + return null; } return flyoutContents[index].getElement(); diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 13e5a729d..927403dea 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -24,6 +24,16 @@ import {Rect} from '../utils/rect.js'; import {WorkspaceSvg} from '../workspace_svg.js'; import {Marker} from './marker.js'; +/** + * Representation of the direction of travel within a navigation context. + */ +export enum NavigationDirection { + NEXT, + PREVIOUS, + IN, + OUT, +} + /** * Class for a line cursor. */ @@ -54,14 +64,8 @@ export class LineCursor extends Marker { } const newNode = this.getNextNode( curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, - true, + this.getValidationFunction(NavigationDirection.NEXT), + this.shouldLoop(NavigationDirection.NEXT), ); if (newNode) { @@ -83,7 +87,11 @@ export class LineCursor extends Marker { return null; } - const newNode = this.getNextNode(curNode, () => true, true); + const newNode = this.getNextNode( + curNode, + this.getValidationFunction(NavigationDirection.IN), + this.shouldLoop(NavigationDirection.IN), + ); if (newNode) { this.setCurNode(newNode); @@ -104,14 +112,8 @@ export class LineCursor extends Marker { } const newNode = this.getPreviousNode( curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, - true, + this.getValidationFunction(NavigationDirection.PREVIOUS), + this.shouldLoop(NavigationDirection.PREVIOUS), ); if (newNode) { @@ -133,7 +135,11 @@ export class LineCursor extends Marker { return null; } - const newNode = this.getPreviousNode(curNode, () => true, true); + const newNode = this.getPreviousNode( + curNode, + this.getValidationFunction(NavigationDirection.OUT), + this.shouldLoop(NavigationDirection.OUT), + ); if (newNode) { this.setCurNode(newNode); @@ -141,6 +147,47 @@ export class LineCursor extends Marker { return newNode; } + /** + * Returns a function that will be used to determine whether a candidate for + * navigation is valid. + * + * @param direction The direction in which the user is navigating. + * @returns A function that takes a proposed navigation candidate and returns + * true if navigation should be allowed to proceed to it, or false to find + * a different candidate. + */ + getValidationFunction( + direction: NavigationDirection, + ): (node: IFocusableNode | null) => boolean { + switch (direction) { + case NavigationDirection.IN: + case NavigationDirection.OUT: + return () => true; + case NavigationDirection.NEXT: + case NavigationDirection.PREVIOUS: + return (candidate: IFocusableNode | null) => { + return ( + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + (!!candidate && + this.workspace.getNavigator().getParent(candidate) === + this.workspace) + ); + }; + } + } + + /** + * Returns whether or not navigation should loop around when reaching the end/ + * beginning of navigable items. + * + * @param direction The direction in which the user is navigating. + * @returns True if navigation should be allowed to loop, otherwise false. + */ + shouldLoop(direction: NavigationDirection): boolean { + return true; + } + /** * 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 diff --git a/core/keyboard_nav/workspace_navigation_policy.ts b/core/keyboard_nav/workspace_navigation_policy.ts index b671f8fe7..57d4a579c 100644 --- a/core/keyboard_nav/workspace_navigation_policy.ts +++ b/core/keyboard_nav/workspace_navigation_policy.ts @@ -5,6 +5,7 @@ */ 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 {WorkspaceSvg} from '../workspace_svg.js'; @@ -21,6 +22,15 @@ export class WorkspaceNavigationPolicy * @returns The top block of the first block stack, if any. */ getFirstChild(current: WorkspaceSvg): IFocusableNode | null { + if (current.isFlyout) { + for (const item of current.targetWorkspace?.getFlyout()?.getContents() ?? + []) { + const element = item.getElement(); + if (isFocusableNode(element) && element.canBeFocused()) { + return element; + } + } + } const blocks = current.getTopBlocks(true); return blocks.length ? blocks[0] : null; }