feat!: add shortcuts to navigate between headings in the flyout (#9874)

* feat!: add shortcuts to jump between headings in the flyout

* feat: show a hint if user presses enter on flyout label
This commit is contained in:
Maribeth Moffatt
2026-05-15 14:29:51 -04:00
committed by GitHub
parent 3d18026767
commit 3c79e6cc49
8 changed files with 412 additions and 6 deletions
+11 -6
View File
@@ -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';
@@ -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);
}
}
}
+16
View File
@@ -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';
@@ -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.
*
+128
View File
@@ -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,
@@ -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';
@@ -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',
}
@@ -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.
*/
@@ -1190,6 +1317,7 @@ export function registerKeyboardNavigationShortcuts() {
registerArrowNavigation();
registerDisconnectBlock();
registerStackNavigation();
registerHeadingNavigation();
registerPerformAction();
registerDuplicate();
registerCleanup();
+3
View File
@@ -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",
@@ -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",
+3
View File
@@ -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.",
@@ -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'.",
+9
View File
@@ -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} */
@@ -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';
@@ -867,3 +867,208 @@ suite('Toolbox and flyout arrow navigation by layout', function () {
});
}
});
suite('Flyout heading navigation (H / Shift+H)', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.defineBlocksWithJsonArray([
{
type: 'basic_block',
message0: '%1',
args0: [
{
type: 'field_input',
name: 'TEXT',
text: 'default',
},
],
},
]);
// Build a flyout toolbox that mixes blocks and headings (labels) so we
// can verify that the H shortcut jumps over non-heading items.
this.workspace = Blockly.inject('blocklyDiv', {
toolbox: {
kind: 'flyoutToolbox',
contents: [
{kind: 'label', text: 'First heading'},
{kind: 'block', type: 'basic_block'},
{kind: 'block', type: 'basic_block'},
{kind: 'label', text: 'Second heading'},
{kind: 'block', type: 'basic_block'},
{kind: 'label', text: 'Third heading'},
{kind: 'block', type: 'basic_block'},
],
},
});
});
teardown(function () {
sharedTestTeardown.call(this);
});
/**
* Returns all FlyoutButton labels (headings) currently in the flyout.
*
* @param {!Blockly.WorkspaceSvg} workspace The main workspace owning the
* flyout.
* @returns {!Array<!Blockly.FlyoutButton>} The labels in flyout order.
*/
function getHeadings(workspace) {
return workspace
.getFlyout()
.getContents()
.map((item) => item.getElement())
.filter(
(element) =>
element instanceof Blockly.FlyoutButton && element.isLabel(),
);
}
test('Shortcut is a no-op when focus is on the main workspace', function () {
Blockly.getFocusManager().focusTree(this.workspace);
const before = Blockly.getFocusManager().getFocusedNode();
pressKey(this.workspace, Blockly.utils.KeyCodes.H);
assert.equal(Blockly.getFocusManager().getFocusedNode(), before);
});
test('Shortcut is a no-op when focus is on a workspace block', function () {
const block = this.workspace.newBlock('basic_block');
block.initSvg();
block.render();
Blockly.getFocusManager().focusNode(block);
pressKey(this.workspace, Blockly.utils.KeyCodes.H);
assert.equal(Blockly.getFocusManager().getFocusedNode(), block);
});
test('H from flyout workspace focuses the first heading', function () {
Blockly.getFocusManager().focusNode(
this.workspace.getFlyout().getWorkspace(),
);
pressKey(this.workspace, Blockly.utils.KeyCodes.H);
const headings = getHeadings(this.workspace);
assert.equal(Blockly.getFocusManager().getFocusedNode(), headings[0]);
});
test('H from a block in the flyout focuses the next heading', function () {
Blockly.getFocusManager().focusNode(
this.workspace.getFlyout().getWorkspace().getTopBlocks()[0],
);
pressKey(this.workspace, Blockly.utils.KeyCodes.H);
const headings = getHeadings(this.workspace);
assert.equal(Blockly.getFocusManager().getFocusedNode(), headings[1]);
});
test('H from a heading focuses the next heading', function () {
const headings = getHeadings(this.workspace);
Blockly.getFocusManager().focusNode(headings[0]);
pressKey(this.workspace, Blockly.utils.KeyCodes.H);
assert.equal(Blockly.getFocusManager().getFocusedNode(), headings[1]);
});
test('H from the last heading does nothing', function () {
const headings = getHeadings(this.workspace);
Blockly.getFocusManager().focusNode(headings[headings.length - 1]);
pressKey(this.workspace, Blockly.utils.KeyCodes.H);
assert.equal(
Blockly.getFocusManager().getFocusedNode(),
headings[headings.length - 1],
);
});
test('Shift+H from flyout workspace focuses the last heading', function () {
Blockly.getFocusManager().focusNode(
this.workspace.getFlyout().getWorkspace(),
);
pressKey(this.workspace, Blockly.utils.KeyCodes.H, [
Blockly.utils.KeyCodes.SHIFT,
]);
const headings = getHeadings(this.workspace);
assert.equal(
Blockly.getFocusManager().getFocusedNode(),
headings[headings.length - 1],
);
});
test('Shift+H from a heading focuses the previous heading', function () {
const headings = getHeadings(this.workspace);
Blockly.getFocusManager().focusNode(headings[2]);
pressKey(this.workspace, Blockly.utils.KeyCodes.H, [
Blockly.utils.KeyCodes.SHIFT,
]);
assert.equal(Blockly.getFocusManager().getFocusedNode(), headings[1]);
});
test('Shift+H from a block focuses the previous heading', function () {
Blockly.getFocusManager().focusNode(
this.workspace.getFlyout().getWorkspace().getTopBlocks()[2],
);
pressKey(this.workspace, Blockly.utils.KeyCodes.H, [
Blockly.utils.KeyCodes.SHIFT,
]);
const headings = getHeadings(this.workspace);
assert.equal(Blockly.getFocusManager().getFocusedNode(), headings[1]);
});
test('Shift+H from the first heading does nothing', function () {
const headings = getHeadings(this.workspace);
Blockly.getFocusManager().focusNode(headings[0]);
pressKey(this.workspace, Blockly.utils.KeyCodes.H, [
Blockly.utils.KeyCodes.SHIFT,
]);
assert.equal(Blockly.getFocusManager().getFocusedNode(), headings[0]);
});
});
suite('Flyout heading navigation with no headings', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.defineBlocksWithJsonArray([
{
type: 'basic_block',
message0: '%1',
args0: [
{
type: 'field_input',
name: 'TEXT',
text: 'default',
},
],
},
]);
this.workspace = Blockly.inject('blocklyDiv', {
toolbox: {
kind: 'flyoutToolbox',
contents: [
{kind: 'block', type: 'basic_block'},
{kind: 'block', type: 'basic_block'},
],
},
});
});
teardown(function () {
sharedTestTeardown.call(this);
});
test('H does nothing when the flyout has no headings', function () {
const firstBlock = this.workspace
.getFlyout()
.getWorkspace()
.getTopBlocks()[0];
Blockly.getFocusManager().focusNode(firstBlock);
pressKey(this.workspace, Blockly.utils.KeyCodes.H);
assert.equal(Blockly.getFocusManager().getFocusedNode(), firstBlock);
});
test('Shift+H does nothing when the flyout has no headings', function () {
const firstBlock = this.workspace
.getFlyout()
.getWorkspace()
.getTopBlocks()[0];
Blockly.getFocusManager().focusNode(firstBlock);
pressKey(this.workspace, Blockly.utils.KeyCodes.H, [
Blockly.utils.KeyCodes.SHIFT,
]);
assert.equal(Blockly.getFocusManager().getFocusedNode(), firstBlock);
});
});
@@ -1499,6 +1499,43 @@ suite('Keyboard Shortcut Items', function () {
ws.dispose();
});
test('Shows a toast with navigation hints for flyout labels', function () {
const ws = Blockly.inject('blocklyDiv', {
toolbox: {
kind: 'flyoutToolbox',
contents: [
{kind: 'label', text: 'A heading'},
{kind: 'block', type: 'stack_block'},
],
},
});
const toastSpy = sinon.spy(Blockly.Toast, 'show');
const label = ws
.getFlyout()
.getContents()
.map((item) => item.getElement())
.find(
(element) =>
element instanceof Blockly.FlyoutButton && element.isLabel(),
);
assert.exists(label, 'Expected a flyout label in the test fixture');
Blockly.getFocusManager().focusNode(label);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
ws.getInjectionDiv().dispatchEvent(event);
sinon.assert.calledWith(toastSpy, ws, {
id: 'flyoutLabelHint',
message: Blockly.Msg['KEYBOARD_NAV_FLYOUT_LABEL_HINT'].replace(
'%1',
'H',
),
});
toastSpy.restore();
ws.dispose();
});
// Reenable this tests once the shortcut listing shortcut has been added.
test.skip('Shows a toast with instructions to view help for non-navigable blocks', function () {
const toastSpy = sinon.spy(Blockly.Toast, 'show');