Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions packages/blockly/core/flyout_button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import * as browserEvents from './browser_events.js';
import * as Css from './css.js';
import * as hints from './hints.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
Expand Down Expand Up @@ -434,14 +435,18 @@ export class FlyoutButton

/**
* Handles the user acting on this button via keyboard navigation.
* Invokes the click handler callback.
* Invokes the click handler callback for buttons. For labels, which are not
* interactive, shows a toast directing the user to navigate using the arrow
* keys or the next-heading shortcut.
*/
performAction(): void {
if (!this.isFlyoutLabel) {
const callback = this.targetWorkspace.getButtonCallback(this.callbackKey);
if (callback) {
callback(this);
}
if (this.isFlyoutLabel) {
hints.showFlyoutLabelActionHint(this.targetWorkspace);
return;
}
const callback = this.targetWorkspace.getButtonCallback(this.callbackKey);
if (callback) {
callback(this);
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions packages/blockly/core/hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const constrainedMoveHintId = 'constrainedMoveHint';
const helpHintId = 'helpHint';
const blockNavigationHintId = 'blockNavigationHint';
const workspaceNavigationHintId = 'workspaceNavigationHint';
const flyoutLabelHintId = 'flyoutLabelHint';
const copiedHintId = 'copiedHint';
const cutHintId = 'cutHint';
const screenreaderHintId = 'screenreaderHint';
Expand Down Expand Up @@ -113,6 +114,21 @@ export function showWorkspaceNavigationHint(workspace: WorkspaceSvg) {
Toast.show(workspace, {message, id});
}

/**
* Tell the user how to navigate away from a flyout label (heading) when they
* try to act on it. Labels are not interactive, so direct them to use the
* arrow keys to reach a block or the next-heading shortcut to skip ahead.
*
* @param workspace The workspace.
*/
export function showFlyoutLabelActionHint(workspace: WorkspaceSvg) {
const message = Msg['KEYBOARD_NAV_FLYOUT_LABEL_HINT'].replace(
'%1',
getShortcutKeysShort(names.NEXT_HEADING),
);
Toast.show(workspace, {message, id: flyoutLabelHintId});
}

/**
* Nudge the user to paste after a copy.
*
Expand Down
128 changes: 128 additions & 0 deletions packages/blockly/core/shortcut_items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {RenderedWorkspaceComment} from './comments.js';
import * as contextmenu from './contextmenu.js';
import * as dropDownDiv from './dropdowndiv.js';
import * as eventUtils from './events/utils.js';
import {FlyoutButton} from './flyout_button.js';
import {getFocusManager} from './focus_manager.js';
import {
clearPasteHints,
Expand All @@ -24,6 +25,7 @@ import {hasContextMenu} from './interfaces/i_contextmenu.js';
import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
import {type IDraggable, isDraggable} from './interfaces/i_draggable.js';
import {type IFlyout} from './interfaces/i_flyout.js';
import {type IFocusableNode} from './interfaces/i_focusable_node.js';
import {isSelectable} from './interfaces/i_selectable.js';
import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js';
Expand Down Expand Up @@ -74,6 +76,8 @@ export enum names {
DUPLICATE = 'duplicate',
CLEANUP = 'cleanup',
SHOW_TOOLTIP = 'show_tooltip',
NEXT_HEADING = 'next_heading',
PREVIOUS_HEADING = 'previous_heading',
TOGGLE_SCREENREADER = 'toggle_screenreader',
}

Expand Down Expand Up @@ -1023,6 +1027,129 @@ export function registerStackNavigation() {
ShortcutRegistry.registry.register(previousStackShortcut);
}

/**
* Registers keyboard shortcuts to jump between headings (labels) in a flyout.
*
* Pressing H moves focus to the next heading; Shift+H moves focus to the
* previous heading. The shortcut only activates when focus is already inside
* the flyout; otherwise it returns false so other handlers may take over.
*/
export function registerHeadingNavigation() {
/**
* Returns the flyout the user is currently focused in, or null if focus is
* not inside any flyout's workspace.
*/
const getActiveFlyout = (): IFlyout | null => {
const focusedTree = getFocusManager().getFocusedTree();
if (
focusedTree instanceof WorkspaceSvg &&
focusedTree.isFlyout &&
focusedTree.targetWorkspace
) {
return focusedTree.targetWorkspace.getFlyout() ?? null;
}
return null;
};

/**
* Walks up from the focused node to find the top-level flyout item it
* belongs to (e.g. the block whose field is focused). Returns null if no
* flyout item is focused.
*/
const getCurrentFlyoutItem = (flyout: IFlyout): IFocusableNode | null => {
const navigator = flyout.getWorkspace().getNavigator();
const items: IFocusableNode[] = flyout
.getContents()
.map((item) => item.getElement());
let node: IFocusableNode | null =
getFocusManager().getFocusedNode() ?? null;
while (node && !items.includes(node)) {
node = navigator.getParent(node);
}
return node;
};

/**
* Finds the next or previous heading in the flyout relative to the
* currently focused item, or the first/last heading if no flyout item is
* focused. Returns null if there is no heading to navigate to.
*/
const findHeading = (
flyout: IFlyout,
direction: 1 | -1,
): FlyoutButton | null => {
const items: IFocusableNode[] = flyout
.getContents()
.map((item) => item.getElement());
const current = getCurrentFlyoutItem(flyout);
// When nothing in the flyout is focused, start from before the first item
// (for next) or after the last item (for previous) so the loop finds the
// first or last heading respectively.
const startIndex = current
? items.indexOf(current)
: direction === 1
? -1
: items.length;

for (let offset = 1; offset <= items.length; offset++) {
const index = startIndex + direction * offset;
if (index < 0 || index >= items.length) break;
const item = items[index];
if (item instanceof FlyoutButton && item.isLabel()) {
return item;
}
}
return null;
};

const shiftH = ShortcutRegistry.registry.createSerializedKey(KeyCodes.H, [
KeyCodes.SHIFT,
]);

const nextHeadingShortcut: KeyboardShortcut = {
name: names.NEXT_HEADING,
preconditionFn: (workspace) =>
!workspace.isDragging() &&
!dropDownDiv.isVisible() &&
!widgetDiv.isVisible() &&
!!getActiveFlyout(),
callback: () => {
const flyout = getActiveFlyout();
if (!flyout) return false;
const target = findHeading(flyout, 1);
if (!target) return false;
keyboardNavigationController.setIsActive(true);
getFocusManager().focusNode(target);
return true;
},
keyCodes: [KeyCodes.H],
displayText: () => Msg['SHORTCUTS_NEXT_HEADING'],
};

const previousHeadingShortcut: KeyboardShortcut = {
name: names.PREVIOUS_HEADING,
preconditionFn: (workspace) =>
!workspace.isDragging() &&
!dropDownDiv.isVisible() &&
!widgetDiv.isVisible() &&
!!getActiveFlyout(),
callback: () => {
const flyout = getActiveFlyout();
if (!flyout) return false;
const target = findHeading(flyout, -1);
if (!target) return false;
keyboardNavigationController.setIsActive(true);
getFocusManager().focusNode(target);
return true;
},
keyCodes: [shiftH],
displayText: () => Msg['SHORTCUTS_PREVIOUS_HEADING'],
};

ShortcutRegistry.registry.register(nextHeadingShortcut);
ShortcutRegistry.registry.register(previousHeadingShortcut);
}

/**
* Registers keyboard shortcut to perform an action on the focused element.
*/
Expand Down Expand Up @@ -1190,6 +1317,7 @@ export function registerKeyboardNavigationShortcuts() {
registerArrowNavigation();
registerDisconnectBlock();
registerStackNavigation();
registerHeadingNavigation();
registerPerformAction();
registerDuplicate();
registerCleanup();
Expand Down
3 changes: 3 additions & 0 deletions packages/blockly/msg/json/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@
"SHORTCUTS_DISCONNECT": "Disconnect block",
"SHORTCUTS_NEXT_STACK": "Next stack",
"SHORTCUTS_PREVIOUS_STACK": "Previous stack",
"SHORTCUTS_NEXT_HEADING": "Next heading",
"SHORTCUTS_PREVIOUS_HEADING": "Previous heading",
"SHORTCUTS_PERFORM_ACTION": "Edit or confirm",
"SHORTCUTS_DUPLICATE": "Duplicate",
"SHORTCUTS_CLEANUP": "Clean up workspace",
Expand All @@ -468,6 +470,7 @@
"WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment",
"KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Use %1 to navigate inside of blocks.",
"KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Use the arrow keys to navigate.",
"KEYBOARD_NAV_FLYOUT_LABEL_HINT": "Use the arrow keys to navigate to a block, or press %1 to go to the next heading.",
"BLOCK_LABEL_BEGIN_STACK": "Begin stack",
"BLOCK_LABEL_BEGIN_PREFIX": "Begin %1",
"BLOCK_LABEL_TOOLBOX_CATEGORY": "%1 category",
Expand Down
3 changes: 3 additions & 0 deletions packages/blockly/msg/json/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,8 @@
"SHORTCUTS_DISCONNECT": "shortcut display text for the disconnect shortcut, which disconnects a block from its neighbor.",
"SHORTCUTS_NEXT_STACK": "shortcut display text for the next stack shortcut, which navigates to the next block stack.",
"SHORTCUTS_PREVIOUS_STACK": "shortcut display text for the previous stack shortcut, which navigates to the previous block stack.",
"SHORTCUTS_NEXT_HEADING": "shortcut display text for the next heading shortcut, which moves focus to the next heading (label) in the flyout.",
"SHORTCUTS_PREVIOUS_HEADING": "shortcut display text for the previous heading shortcut, which moves focus to the previous heading (label) in the flyout.",
"SHORTCUTS_PERFORM_ACTION": "shortcut display text for the perform action shortcut, which triggers an action on the focused element.",
"SHORTCUTS_DUPLICATE": "shortcut display text for the duplicate shortcut, which duplicates the focused block or comment.",
"SHORTCUTS_CLEANUP": "shortcut display text for the cleanup shortcut, which organizes blocks on the workspace.",
Expand All @@ -462,6 +464,7 @@
"WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment.",
"KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Message shown when a user presses Enter with a navigable block focused.",
"KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Message shown when a user presses Enter with the workspace focused.",
"KEYBOARD_NAV_FLYOUT_LABEL_HINT": "Message shown when a user presses Enter with a flyout label (heading) focused. Placeholder %1 is the keyboard shortcut for navigating to the next heading.",
"BLOCK_LABEL_BEGIN_STACK": "Part of an accessibility label for a block that indicates it is the first block in the stack.",
"BLOCK_LABEL_BEGIN_PREFIX": "Part of an accessibility label for a block that indicates it is the first block inside of a statement input. Placeholder corresponds to the parent statement input's accessibility label.",
"BLOCK_LABEL_TOOLBOX_CATEGORY": "Part of an accessibility label for a block that indicates its parent toolbox category. Placeholder corresponds to a category name, e.g. 'Logic' or 'Math'.",
Expand Down
9 changes: 9 additions & 0 deletions packages/blockly/msg/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -1775,6 +1775,12 @@ Blockly.Msg.SHORTCUTS_NEXT_STACK = 'Next stack';
/// shortcut display text for the previous stack shortcut, which navigates to the previous block stack.
Blockly.Msg.SHORTCUTS_PREVIOUS_STACK = 'Previous stack';
/** @type {string} */
/// shortcut display text for the next heading shortcut, which moves focus to the next heading (label) in the flyout.
Blockly.Msg.SHORTCUTS_NEXT_HEADING = 'Next heading';
/** @type {string} */
/// shortcut display text for the previous heading shortcut, which moves focus to the previous heading (label) in the flyout.
Blockly.Msg.SHORTCUTS_PREVIOUS_HEADING = 'Previous heading';
/** @type {string} */
/// shortcut display text for the perform action shortcut, which triggers an action on the focused element.
Blockly.Msg.SHORTCUTS_PERFORM_ACTION = 'Edit or confirm';
/** @type {string} */
Expand Down Expand Up @@ -1850,6 +1856,9 @@ Blockly.Msg.KEYBOARD_NAV_BLOCK_NAVIGATION_HINT = 'Use %1 to navigate inside of b
/// Message shown when a user presses Enter with the workspace focused.
Blockly.Msg.KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT = 'Use the arrow keys to navigate.';
/** @type {string} */
/// Message shown when a user presses Enter with a flyout label (heading) focused.
Blockly.Msg.KEYBOARD_NAV_FLYOUT_LABEL_HINT = 'Use the arrow keys to navigate to a block, or press %1 to go to the next heading.';
/** @type {string} */
/// Part of an accessibility label for a block that indicates it is the first
/// block in the stack.
Blockly.Msg.BLOCK_LABEL_BEGIN_STACK = 'Begin stack';
Expand Down
Loading
Loading