Skip to content
4 changes: 4 additions & 0 deletions src/actions/enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Field,
icons,
FocusableTreeTraverser,
renderManagement,
} from 'blockly/core';

import type {Block} from 'blockly/core';
Expand Down Expand Up @@ -152,6 +153,9 @@ export class EnterAction {
return true;
} else if (curNode instanceof icons.Icon) {
curNode.onClick();
renderManagement.finishQueuedRenders().then(() => {
cursor?.in();
});
return true;
}
return false;
Expand Down
21 changes: 21 additions & 0 deletions src/actions/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
utils as BlocklyUtils,
getFocusManager,
Gesture,
icons,
} from 'blockly/core';

import * as Constants from '../constants';
Expand Down Expand Up @@ -39,6 +40,26 @@ export class ExitAction {
workspace.hideChaff();
}
return true;
case Constants.STATE.WORKSPACE: {
if (workspace.isMutator) {
const parent = workspace.options.parentWorkspace
?.getAllBlocks()
.map((block) => block.getIcons())
.flat()
.find(
(icon): icon is icons.MutatorIcon =>
icon instanceof icons.MutatorIcon &&
icon.bubbleIsVisible() &&
icon.getBubble()?.getWorkspace() === workspace,
);
if (parent) {
parent.setBubbleVisible(false);
getFocusManager().focusNode(parent);
return true;
}
}
return false;
}
default:
return false;
}
Expand Down
9 changes: 6 additions & 3 deletions src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,9 +824,12 @@ export class Navigation {
* @returns whether keyboard navigation is currently allowed.
*/
canCurrentlyNavigate(workspace: Blockly.WorkspaceSvg) {
const accessibilityMode = workspace.isFlyout
? workspace.targetWorkspace?.keyboardAccessibilityMode
: workspace.keyboardAccessibilityMode;
const accessibilityMode = (
workspace.getRootWorkspace() ??
workspace.targetWorkspace?.getRootWorkspace() ??
workspace.targetWorkspace ??
workspace
).keyboardAccessibilityMode;
return (
!!accessibilityMode &&
this.getState() !== Constants.STATE.NOWHERE &&
Expand Down
107 changes: 107 additions & 0 deletions test/webdriverio/test/mutator_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import * as chai from 'chai';
import * as Blockly from 'blockly';
import {
focusedTreeIsMainWorkspace,
focusOnBlock,
getCurrentFocusNodeId,
getFocusedBlockType,
testSetup,
testFileLocations,
PAUSE_TIME,
tabNavigateToWorkspace,
keyRight,
keyDown,
} from './test_setup.js';
import {Key} from 'webdriverio';

suite('Mutator navigation', function () {
// Setting timeout to unlimited as these tests take a longer time to run than most mocha test
this.timeout(0);

// Setup Selenium for all of the tests
setup(async function () {
this.browser = await testSetup(testFileLocations.NAVIGATION_TEST_BLOCKS);
this.openMutator = async () => {
await tabNavigateToWorkspace(this.browser);
await this.browser.pause(PAUSE_TIME);
await focusOnBlock(this.browser, 'controls_if_1');
await this.browser.pause(PAUSE_TIME);
// Navigate to the mutator icon
await keyRight(this.browser);
// Activate the icon
await this.browser.keys(Key.Enter);
await this.browser.pause(PAUSE_TIME);
};
});

test('Enter opens mutator', async function () {
await this.openMutator();

// Main workspace should not be focused (because mutator workspace is)
const mainWorkspaceFocused = await focusedTreeIsMainWorkspace(this.browser);
chai.assert.isFalse(mainWorkspaceFocused);

// The "if" placeholder block in the mutator should be focused
const focusedBlockType = await getFocusedBlockType(this.browser);
chai.assert.equal(focusedBlockType, 'controls_if_if');
});

test('Escape dismisses mutator', async function () {
await this.openMutator();
await this.browser.keys(Key.Escape);
await this.browser.pause(PAUSE_TIME);

// Main workspace should be the focused tree (since mutator workspace is gone)
const mainWorkspaceFocused = await focusedTreeIsMainWorkspace(this.browser);
chai.assert.isTrue(mainWorkspaceFocused);

const mutatorIconId = await this.browser.execute(() => {
const block = Blockly.getMainWorkspace().getBlockById('controls_if_1');
const icon = block?.getIcon(Blockly.icons.IconType.MUTATOR);
return icon?.getFocusableElement().id;
});

// Mutator icon should now be focused
const focusedNodeId = await getCurrentFocusNodeId(this.browser);
chai.assert.equal(mutatorIconId, focusedNodeId);
});

test('T focuses the mutator flyout', async function () {
await this.openMutator();
await this.browser.keys('t');
await this.browser.pause(PAUSE_TIME);

// The "else if" block in the mutator flyout should be focused
const focusedBlockType = await getFocusedBlockType(this.browser);
chai.assert.equal(focusedBlockType, 'controls_if_elseif');
});

test('Blocks can be inserted from the mutator flyout', async function () {
await this.openMutator();
await this.browser.keys('t');
await this.browser.pause(PAUSE_TIME);
// Navigate down to the second block in the flyout
await keyDown(this.browser);
await this.browser.pause(PAUSE_TIME);
// Hit enter to enter insert mode
await this.browser.keys(Key.Enter);
await this.browser.pause(PAUSE_TIME);
// Hit enter again to lock it into place on the connection
await this.browser.keys(Key.Enter);

const topBlocks = await this.browser.execute(() => {
const focusedTree = Blockly.getFocusManager().getFocusedTree();
if (!(focusedTree instanceof Blockly.WorkspaceSvg)) return [];

return focusedTree.getAllBlocks(true).map((block) => block.type);
});

chai.assert.deepEqual(topBlocks, ['controls_if_if', 'controls_if_else']);
});
});
14 changes: 14 additions & 0 deletions test/webdriverio/test/test_setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,20 @@ export async function currentFocusIsMainWorkspace(
});
}

/**
* Returns whether the currently focused tree is the main workspace.
*
* @param browser The active WebdriverIO Browser object.
*/
export async function focusedTreeIsMainWorkspace(
browser: WebdriverIO.Browser,
): Promise<boolean> {
return await browser.execute(() => {
const workspaceSvg = Blockly.getMainWorkspace() as Blockly.WorkspaceSvg;
return Blockly.getFocusManager().getFocusedTree() === workspaceSvg;
});
}

/**
* Focuses and selects a block with the provided ID.
*
Expand Down