Skip to content

Commit 41319ba

Browse files
authored
feat: Add keyboard shortcut to perform an action on the currently focused element (#9673)
* feat: Add keyboard shortcut to perform an action on the currently focused element * test: Add tests * chore: Add example of shortcut formats * fix: Don't show toast if shortcut doesn't exist * chore: Clarify use of Zelos in tests * fix: Skip help hint toast until shortcut is added
1 parent 5bc04b6 commit 41319ba

18 files changed

Lines changed: 544 additions & 20 deletions

packages/blockly/core/block_svg.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {EventType} from './events/type.js';
3535
import * as eventUtils from './events/utils.js';
3636
import {FieldLabel} from './field_label.js';
3737
import {getFocusManager} from './focus_manager.js';
38+
import * as hints from './hints.js';
3839
import {IconType} from './icons/icon_types.js';
3940
import {MutatorIcon} from './icons/mutator_icon.js';
4041
import {WarningIcon} from './icons/warning_icon.js';
@@ -52,6 +53,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js';
5253
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
5354
import {IIcon} from './interfaces/i_icon.js';
5455
import * as internalConstants from './internal_constants.js';
56+
import {KeyboardMover} from './keyboard_nav/keyboard_mover.js';
5557
import {Msg} from './msg.js';
5658
import * as renderManagement from './render_management.js';
5759
import {RenderedConnection} from './rendered_connection.js';
@@ -1903,6 +1905,34 @@ export class BlockSvg
19031905
return true;
19041906
}
19051907

1908+
/**
1909+
* Handles the user acting on this block via keyboard navigation.
1910+
* If this block is in the flyout, a new copy is spawned in move mode on the
1911+
* main workspace. If this block has a single full-block field, that field
1912+
* will be focused. Otherwise, this is a no-op.
1913+
*/
1914+
performAction() {
1915+
if (this.workspace.isFlyout) {
1916+
KeyboardMover.mover.startMove(this);
1917+
return;
1918+
} else if (this.isSimpleReporter()) {
1919+
for (const input of this.inputList) {
1920+
for (const field of input.fieldRow) {
1921+
if (field.isClickable() && field.isFullBlockField()) {
1922+
field.showEditor();
1923+
return;
1924+
}
1925+
}
1926+
}
1927+
}
1928+
1929+
if (this.workspace.getNavigator().getFirstChild(this)) {
1930+
hints.showBlockNavigationHint(this.workspace);
1931+
} else {
1932+
hints.showHelpHint(this.workspace);
1933+
}
1934+
}
1935+
19061936
/**
19071937
* Returns a set of all of the parent blocks of the given block.
19081938
*

packages/blockly/core/bubbles/mini_workspace_bubble.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import type {BlocklyOptions} from '../blockly_options.js';
88
import {Abstract as AbstractEvent} from '../events/events_abstract.js';
9+
import {getFocusManager} from '../focus_manager.js';
910
import {KeyboardMover} from '../keyboard_nav/keyboard_mover.js';
1011
import {Options} from '../options.js';
1112
import {Coordinate} from '../utils/coordinate.js';
@@ -287,4 +288,12 @@ export class MiniWorkspaceBubble extends Bubble {
287288
'monkey-patched in by blockly.ts',
288289
);
289290
}
291+
292+
/**
293+
* Handles the user acting on this bubble via keyboard navigation by focusing
294+
* the mutator workspace.
295+
*/
296+
performAction() {
297+
getFocusManager().focusTree(this.getWorkspace());
298+
}
290299
}

packages/blockly/core/bubbles/textinput_bubble.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,14 @@ export class TextInputBubble extends Bubble {
279279
getEditor() {
280280
return this.editor;
281281
}
282+
283+
/**
284+
* Handles the user acting on this bubble via keyboard navigation by focusing
285+
* the comment editor.
286+
*/
287+
performAction() {
288+
getFocusManager().focusNode(this.getEditor());
289+
}
282290
}
283291

284292
Css.register(`

packages/blockly/core/comments/rendered_workspace_comment.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,4 +358,13 @@ export class RenderedWorkspaceComment
358358
canBeFocused(): boolean {
359359
return true;
360360
}
361+
362+
/**
363+
* Handles the user acting on this comment via keyboard navigation.
364+
* Expands the comment and focuses its editor.
365+
*/
366+
performAction() {
367+
this.setCollapsed(false);
368+
getFocusManager().focusNode(this.getEditorFocusableNode());
369+
}
361370
}

packages/blockly/core/field.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,6 +1488,14 @@ export abstract class Field<T = any>
14881488
return true;
14891489
}
14901490

1491+
/**
1492+
* Handles the user acting on this field via keyboard navigation.
1493+
* Shows and focuses the field editor.
1494+
*/
1495+
performAction() {
1496+
this.showEditor();
1497+
}
1498+
14911499
/**
14921500
* Subclasses should reimplement this method to construct their Field
14931501
* subclass from a JSON arg object.

packages/blockly/core/flyout_button.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,19 @@ export class FlyoutButton
421421
getId() {
422422
return this.id;
423423
}
424+
425+
/**
426+
* Handles the user acting on this button via keyboard navigation.
427+
* Invokes the click handler callback.
428+
*/
429+
performAction(): void {
430+
if (!this.isFlyoutLabel) {
431+
const callback = this.targetWorkspace.getButtonCallback(this.callbackKey);
432+
if (callback) {
433+
callback(this);
434+
}
435+
}
436+
}
424437
}
425438

426439
/** CSS for buttons and labels. See css.js for use. */

packages/blockly/core/hints.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66

77
import {Msg} from './msg.js';
88
import {Toast} from './toast.js';
9+
import {getShortActionShortcut} from './utils/shortcut_formatting.js';
910
import * as userAgent from './utils/useragent.js';
1011
import type {WorkspaceSvg} from './workspace_svg.js';
1112

1213
const unconstrainedMoveHintId = 'unconstrainedMoveHint';
1314
const constrainedMoveHintId = 'constrainedMoveHint';
15+
const helpHintId = 'helpHint';
16+
const blockNavigationHintId = 'blockNavigationHint';
17+
const workspaceNavigationHintId = 'workspaceNavigationHint';
1418

1519
/**
1620
* Nudge the user to use unconstrained movement.
@@ -62,3 +66,39 @@ export function clearMoveHints(workspace: WorkspaceSvg) {
6266
Toast.hide(workspace, constrainedMoveHintId);
6367
Toast.hide(workspace, unconstrainedMoveHintId);
6468
}
69+
70+
/**
71+
* Nudge the user to open the help.
72+
*
73+
* @param workspace The workspace.
74+
*/
75+
export function showHelpHint(workspace: WorkspaceSvg) {
76+
const shortcut = getShortActionShortcut('list_shortcuts');
77+
if (!shortcut) return;
78+
79+
const message = Msg['HELP_PROMPT'].replace('%1', shortcut);
80+
const id = helpHintId;
81+
Toast.show(workspace, {message, id});
82+
}
83+
84+
/**
85+
* Tell the user how to navigate inside blocks.
86+
*
87+
* @param workspace The workspace.
88+
*/
89+
export function showBlockNavigationHint(workspace: WorkspaceSvg) {
90+
const message = Msg['KEYBOARD_NAV_BLOCK_NAVIGATION_HINT'];
91+
const id = blockNavigationHintId;
92+
Toast.show(workspace, {message, id});
93+
}
94+
95+
/**
96+
* Tell the user how to navigate inside the workspace.
97+
*
98+
* @param workspace The workspace.
99+
*/
100+
export function showWorkspaceNavigationHint(workspace: WorkspaceSvg) {
101+
const message = Msg['KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT'];
102+
const id = workspaceNavigationHintId;
103+
Toast.show(workspace, {message, id});
104+
}

packages/blockly/core/icons/icon.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
import type {Block} from '../block.js';
88
import type {BlockSvg} from '../block_svg.js';
99
import * as browserEvents from '../browser_events.js';
10+
import {getFocusManager} from '../focus_manager.js';
1011
import type {IContextMenu} from '../interfaces/i_contextmenu.js';
1112
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
1213
import {hasBubble} from '../interfaces/i_has_bubble.js';
1314
import type {IIcon} from '../interfaces/i_icon.js';
15+
import * as renderManagement from '../render_management.js';
1416
import * as tooltip from '../tooltip.js';
1517
import {Coordinate} from '../utils/coordinate.js';
1618
import * as dom from '../utils/dom.js';
@@ -189,6 +191,22 @@ export abstract class Icon implements IIcon, IContextMenu {
189191
return true;
190192
}
191193

194+
/**
195+
* Handles the user acting on this icon via keyboard navigation.
196+
* Performs the same action as a click would, and focuses this icon's bubble
197+
* if it has one.
198+
*/
199+
performAction() {
200+
this.onClick();
201+
renderManagement.finishQueuedRenders().then(() => {
202+
if (hasBubble(this) && this.bubbleIsVisible()) {
203+
const bubble = this.getBubble();
204+
if (!bubble) return;
205+
getFocusManager().focusNode(bubble);
206+
}
207+
});
208+
}
209+
192210
/**
193211
* Returns the block that this icon is attached to.
194212
*

packages/blockly/core/interfaces/i_focusable_node.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ export interface IFocusableNode {
9999
* @returns Whether this node can be focused by FocusManager.
100100
*/
101101
canBeFocused(): boolean;
102+
103+
/**
104+
* Optional method invoked when this node has focus and the user acts on it by
105+
* pressing Enter or Space. Behavior should generally be similar to the node
106+
* being clicked on.
107+
*/
108+
performAction?(): void;
102109
}
103110

104111
/**

packages/blockly/core/keyboard_nav/keyboard_mover.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export class KeyboardMover {
102102
* @param event The keyboard event that triggered this move.
103103
* @returns True iff a move has successfully begun.
104104
*/
105-
startMove(draggable: IDraggable, event: KeyboardEvent) {
105+
startMove(draggable: IDraggable, event?: KeyboardEvent) {
106106
if (!this.canMove(draggable) || this.isMoving()) return false;
107107

108108
const DraggerClass = registry.getClassFromOptions(

0 commit comments

Comments
 (0)