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..d0a766daff2 --- /dev/null +++ b/core/keyboard_navigation_controller.ts @@ -0,0 +1,63 @@ +/** + * @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 a user is actively using keyboard navigation. + * + * 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) { + this.isActive = isUsing; + this.updateActiveVisualization(); + } + + /** + * @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 { + 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(); 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'), + ); + }); +});