From 316780e96489e5d0e940d02938088c253808fc9e Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 22 Apr 2025 15:14:06 -0700 Subject: [PATCH 1/3] feat: add keyboard navigation controller --- core/blockly.ts | 6 ++++ core/gesture.ts | 5 +++ core/keyboard_navigation_controller.ts | 50 ++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 core/keyboard_navigation_controller.ts diff --git a/core/blockly.ts b/core/blockly.ts index 14383a947a3..99112d790fb 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -173,6 +173,10 @@ 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 { + 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'; @@ -580,6 +584,7 @@ export { ImageProperties, Input, InsertionMarkerPreviewer, + KeyboardNavigationController, LabelFlyoutInflater, LayerManager, Marker, @@ -631,6 +636,7 @@ export { isSelectable, isSerializable, isVariableBackedParameterModel, + keyboardNavigationController, layers, renderManagement, serialization, diff --git a/core/gesture.ts b/core/gesture.ts index f9b435c67d9..4c65c1d3842 100644 --- a/core/gesture.ts +++ b/core/gesture.ts @@ -31,6 +31,7 @@ import {IDraggable, isDraggable} from './interfaces/i_draggable.js'; import {IDragger} from './interfaces/i_dragger.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IIcon} from './interfaces/i_icon.js'; +import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import * as registry from './registry.js'; import * as Tooltip from './tooltip.js'; import * as Touch from './touch.js'; @@ -541,8 +542,10 @@ export class Gesture { // have higher priority than workspaces. The ordering within drags does // not matter, because the three types of dragging are exclusive. if (this.dragger) { + keyboardNavigationController.setIsActive(false); this.dragger.onDragEnd(e, this.currentDragDeltaXY); } else if (this.workspaceDragger) { + keyboardNavigationController.setIsActive(false); this.workspaceDragger.endDrag(this.currentDragDeltaXY); } else if (this.isBubbleClick()) { // Do nothing, bubbles don't currently respond to clicks. @@ -743,6 +746,8 @@ export class Gesture { e.preventDefault(); e.stopPropagation(); + keyboardNavigationController.setIsActive(false); + this.dispose(); } diff --git a/core/keyboard_navigation_controller.ts b/core/keyboard_navigation_controller.ts new file mode 100644 index 00000000000..ba6c6584a68 --- /dev/null +++ b/core/keyboard_navigation_controller.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The KeyboardNavigationController handles coordinating Blockly-wide + * keyboard navigation behavior, such as enabling/disabling full + * cursor visualization. + */ +export class KeyboardNavigationController { + /** Whether the user is actively using keyboard navigation. */ + private isActive = false; + /** Css class name added to body if keyboard nav is active. */ + private activeClassName = 'blocklyKeyboardNavigation'; + + /** + * Sets whether we think the user is actively using keyboard navigation. + * + * If they are, we'll apply a css class to the entire page so that + * focused items can apply additional styling for keyboard users. + * + * @param isUsing + */ + setIsActive(isUsing: boolean = true) { + this.isActive = isUsing; + this.updateActiveVisualization(); + } + + /** + * @returns true if we think the user is actively using keyboard navigation + * (e.g., has recently taken some action that is only relevant to keyboard users) + */ + getIsActive(): boolean { + return this.isActive; + } + + /** Adds or removes the css class that indicates keyboard navigation is active. */ + private updateActiveVisualization() { + if (this.isActive) { + document.body.classList.add(this.activeClassName); + } else { + document.body.classList.remove(this.activeClassName); + } + } +} + +/** Singleton instance of the keyboard navigation controller. */ +export const keyboardNavigationController = new KeyboardNavigationController(); From 696941107874b32e8851e682ba546bd4501576c3 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 28 May 2025 14:40:01 -0700 Subject: [PATCH 2/3] chore: add tests --- tests/mocha/index.html | 1 + .../keyboard_navigation_controller_test.js | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 tests/mocha/keyboard_navigation_controller_test.js diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 09ef8820f0e..756580adb68 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -240,6 +240,7 @@ import './jso_deserialization_test.js'; import './jso_serialization_test.js'; import './json_test.js'; + import './keyboard_navigation_controller_test.js'; import './layering_test.js'; import './blocks/lists_test.js'; import './blocks/logic_ternary_test.js'; diff --git a/tests/mocha/keyboard_navigation_controller_test.js b/tests/mocha/keyboard_navigation_controller_test.js new file mode 100644 index 00000000000..c7abd863ec1 --- /dev/null +++ b/tests/mocha/keyboard_navigation_controller_test.js @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Keyboard Navigation Controller', function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.keyboardNavigationController.setIsActive(false); + }); + + teardown(function () { + sharedTestTeardown.call(this); + Blockly.keyboardNavigationController.setIsActive(false); + }); + + test('Setting active keyboard navigation adds css class', function () { + Blockly.keyboardNavigationController.setIsActive(true); + assert.isTrue( + document.body.classList.contains('blocklyKeyboardNavigation'), + ); + }); + + test('Disabling active keyboard navigation removes css class', function () { + Blockly.keyboardNavigationController.setIsActive(false); + assert.isFalse( + document.body.classList.contains('blocklyKeyboardNavigation'), + ); + }); +}); From 47efeb2a5d8c13aa0e4a0865f61ec08fe6da8b28 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 28 May 2025 16:46:51 -0700 Subject: [PATCH 3/3] chore: fix tsdoc --- core/keyboard_navigation_controller.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/core/keyboard_navigation_controller.ts b/core/keyboard_navigation_controller.ts index ba6c6584a68..d0a766daff2 100644 --- a/core/keyboard_navigation_controller.ts +++ b/core/keyboard_navigation_controller.ts @@ -16,11 +16,24 @@ export class KeyboardNavigationController { private activeClassName = 'blocklyKeyboardNavigation'; /** - * Sets whether we think the user is actively using keyboard navigation. + * Sets whether a user is actively using keyboard navigation. * - * If they are, we'll apply a css class to the entire page so that + * If they are, apply a css class to the entire page so that * focused items can apply additional styling for keyboard users. * + * Note that since enabling keyboard navigation presents significant UX changes + * (such as cursor visualization and move mode), callers should take care to + * only set active keyboard navigation when they have a high confidence in that + * being the correct state. In general, in any given mouse or key input situation + * callers can choose one of three paths: + * 1. Do nothing. This should be the choice for neutral actions that don't + * predominantly imply keyboard or mouse usage (such as clicking to select a block). + * 2. Disable keyboard navigation. This is the best choice when a user is definitely + * predominantly using the mouse (such as using a right click to open the context menu). + * 3. Enable keyboard navigation. This is the best choice when there's high confidence + * a user actually intends to use it (such as attempting to use the arrow keys to move + * around). + * * @param isUsing */ setIsActive(isUsing: boolean = true) { @@ -29,7 +42,7 @@ export class KeyboardNavigationController { } /** - * @returns true if we think the user is actively using keyboard navigation + * @returns true if the user is actively using keyboard navigation * (e.g., has recently taken some action that is only relevant to keyboard users) */ getIsActive(): boolean {