Skip to content

Commit fdeaa76

Browse files
authored
feat: Update line cursor to use focus manager (#8941)
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #8940 Fixes #8954 Fixes #8955 ### Proposed Changes This updates `LineCursor` to use `FocusManager` rather than selection (principally) as the source of truth. ### Reason for Changes Ensuring that keyboard navigation works correctly with eventual screen reader support requires ensuring that ever navigated component is focused, and this is primarily what `FocusManager` has been designed to do. Since these nodes are already focused, `FocusManager` can be used as the primary source of truth for determining where the user currently has navigated, and where to go next. Previously, `LineCursor` relied on selection for this purpose, but selection is now automatically updated (for blocks) using focus-controlled `focus` and `blur` callbacks. Note that the cursor will still fall back to synchronizing with selection state, though this will be removed once the remaining work to eliminate `MarkerSvg` has concluded (which requires further consideration on the keyboard navigation side viz-a-viz styling and CSS decisions) and once mouse clicks are synchronized with focus management. Note that the changes in this PR are closely tied to RaspberryPiFoundation/blockly-keyboard-experimentation#482 as both are necessary in order for the keyboard navigation plugin to correctly work with `FocusManager`. Some other noteworthy changes: - Some special handling exists for flyouts to handle navigating across stacks (per the current cursor design). - `FocusableTreeTraverser` is needed by the keyboard navigation plugin (in RaspberryPiFoundation/blockly-keyboard-experimentation#482) so it's now being exported. - `FocusManager` had one bug that's now patched and tested in this PR: it didn't handle the case of the browser completely forcing focus loss. It would continue to maintain active focus even though no tracked elements now hold focus. One such case is the element being deleted, but there are other cases where this can happen (such as with dialog prompts). - `FocusManager` had some issues from #8909 wherein it would overeagerly call tree focus callbacks and slightly mismanage the passive node. Since tests haven't yet been added for these lifecycle callbacks, these cases weren't originally caught (per #8910). - `FocusManager` was updated to move the tracked manager into a static function so that it can be replaced in tests. This was done to facilitate changes to setup_teardown.js to ensure that a unique `FocusManager` exists _per-test_. It's possible for DOM focus state to still bleed across tests, but `FocusManager` largely guarantees eventual consistency. This change prevents a class of focus errors from being possible when running tests. - A number of cursor tests needed to be updated to ensure that a connections are properly rendered (as this is a requirement for focusable nodes, and cursor is now focusing nodes). One test for output connections was changed to use an input connection, instead, since output connections can no longer be navigated to (and aren't rendered, thus are not focusable). It's possible this will need to be changed in the future if we decide to reintroduce support for output connections in cursor, but it seems like a reasonable stopgap. Huge thanks to @rachel-fenichel for helping investigate and providing an alternative for the output connection test. **Current gaps** to be fixed after this PR is merged: - The flyout automatically closes when creating a variable with with keyboard or mouse (I think this is only for the keyboard navigation plugin). I believe this is a regression from previous behavior due to how the navigation plugin is managing state. It would know the flyout should be open and thus ensure it stays open even when things like dialog prompts try to close it with a blur event. However, the new implementation in RaspberryPiFoundation/blockly-keyboard-experimentation#482 complicates this since state is now inferred from `FocusManager`, and the flyout _losing_ focus will force it closed. There was a fix introduced in this PR to fix it for keyboard navigation, but fails for clicks because the flyout never receives focus when the create variable button is clicked. It also caused the advanced compilation tests to fail due to a subtle circular dependency from importing `WorkspaceSvg` directly rather than its type. - The flyout, while it stays open, does not automatically update past the first variable being created without closing and reopening it. I'm actually not at all sure why this particular behavior has regressed. ### Test Coverage No new non-`FocusManager` tests have been added. It's certainly possible to add unit tests for the focusable configurations being introduced in this PR, but it may not be highly beneficial. It's largely assumed that the individual implementations should work due to a highly tested FocusManager, and it may be the case that the interactions of the components working together is far more important to verify (that is, the end user flows). The latter is planned to be tackled as part of #8915. Some new `FocusManager` tests were added, but more are still needed and this is tracked as part of #8910. ### Documentation No new documentation should be needed for these changes. ### Additional Information This includes changes that have been pulled from #8875.
1 parent 45c1426 commit fdeaa76

File tree

6 files changed

+181
-141
lines changed

6 files changed

+181
-141
lines changed

core/blockly.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ import * as inputs from './inputs.js';
120120
import {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
121121
import {LabelFlyoutInflater} from './label_flyout_inflater.js';
122122
import {SeparatorFlyoutInflater} from './separator_flyout_inflater.js';
123+
import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
123124

124125
import {Input} from './inputs/input.js';
125126
import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js';
@@ -527,6 +528,7 @@ export {
527528
FlyoutMetricsManager,
528529
FlyoutSeparator,
529530
FocusManager,
531+
FocusableTreeTraverser,
530532
CodeGenerator as Generator,
531533
Gesture,
532534
Grid,

core/focus_manager.ts

Lines changed: 54 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -65,26 +65,15 @@ export class FocusManager {
6565
constructor(
6666
addGlobalEventListener: (type: string, listener: EventListener) => void,
6767
) {
68-
// Register root document focus listeners for tracking when focus leaves all
69-
// tracked focusable trees.
70-
addGlobalEventListener('focusin', (event) => {
71-
if (!(event instanceof FocusEvent)) return;
72-
73-
// The target that now has focus.
74-
const activeElement = document.activeElement;
68+
// Note that 'element' here is the element *gaining* focus.
69+
const maybeFocus = (element: Element | EventTarget | null) => {
7570
let newNode: IFocusableNode | null | undefined = null;
76-
if (
77-
activeElement instanceof HTMLElement ||
78-
activeElement instanceof SVGElement
79-
) {
80-
// If the target losing focus maps to any tree, then it should be
81-
// updated. Per the contract of findFocusableNodeFor only one tree
82-
// should claim the element.
71+
if (element instanceof HTMLElement || element instanceof SVGElement) {
72+
// If the target losing or gaining focus maps to any tree, then it
73+
// should be updated. Per the contract of findFocusableNodeFor only one
74+
// tree should claim the element, so the search can be exited early.
8375
for (const tree of this.registeredTrees) {
84-
newNode = FocusableTreeTraverser.findFocusableNodeFor(
85-
activeElement,
86-
tree,
87-
);
76+
newNode = FocusableTreeTraverser.findFocusableNodeFor(element, tree);
8877
if (newNode) break;
8978
}
9079
}
@@ -103,6 +92,26 @@ export class FocusManager {
10392
} else {
10493
this.defocusCurrentFocusedNode();
10594
}
95+
};
96+
97+
// Register root document focus listeners for tracking when focus leaves all
98+
// tracked focusable trees. Note that focusin and focusout can be somewhat
99+
// overlapping in the information that they provide. This is fine because
100+
// they both aim to check for focus changes on the element gaining or having
101+
// received focus, and maybeFocus should behave relatively deterministic.
102+
addGlobalEventListener('focusin', (event) => {
103+
if (!(event instanceof FocusEvent)) return;
104+
105+
// When something receives focus, always use the current active element as
106+
// the source of truth.
107+
maybeFocus(document.activeElement);
108+
});
109+
addGlobalEventListener('focusout', (event) => {
110+
if (!(event instanceof FocusEvent)) return;
111+
112+
// When something loses focus, it seems that document.activeElement may
113+
// not necessarily be correct. Instead, use relatedTarget.
114+
maybeFocus(event.relatedTarget);
106115
});
107116
}
108117

@@ -247,7 +256,7 @@ export class FocusManager {
247256

248257
const prevNode = this.focusedNode;
249258
const prevTree = prevNode?.getFocusableTree();
250-
if (prevNode && prevTree !== nextTree) {
259+
if (prevNode) {
251260
this.passivelyFocusNode(prevNode, nextTree);
252261
}
253262

@@ -374,7 +383,9 @@ export class FocusManager {
374383
// node's focusable element (which *is* allowed to be invisible until the
375384
// node needs to be focused).
376385
this.lockFocusStateChanges = true;
377-
node.getFocusableTree().onTreeFocus(node, prevTree);
386+
if (node.getFocusableTree() !== prevTree) {
387+
node.getFocusableTree().onTreeFocus(node, prevTree);
388+
}
378389
node.onNodeFocus();
379390
this.lockFocusStateChanges = false;
380391

@@ -399,11 +410,15 @@ export class FocusManager {
399410
nextTree: IFocusableTree | null,
400411
): void {
401412
this.lockFocusStateChanges = true;
402-
node.getFocusableTree().onTreeBlur(nextTree);
413+
if (node.getFocusableTree() !== nextTree) {
414+
node.getFocusableTree().onTreeBlur(nextTree);
415+
}
403416
node.onNodeBlur();
404417
this.lockFocusStateChanges = false;
405418

406-
this.setNodeToVisualPassiveFocus(node);
419+
if (node.getFocusableTree() !== nextTree) {
420+
this.setNodeToVisualPassiveFocus(node);
421+
}
407422
}
408423

409424
/**
@@ -441,19 +456,24 @@ export class FocusManager {
441456
dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
442457
dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
443458
}
444-
}
445459

446-
let focusManager: FocusManager | null = null;
460+
private static focusManager: FocusManager | null = null;
447461

448-
/**
449-
* Returns the page-global FocusManager.
450-
*
451-
* The returned instance is guaranteed to not change across function calls, but
452-
* may change across page loads.
453-
*/
454-
export function getFocusManager(): FocusManager {
455-
if (!focusManager) {
456-
focusManager = new FocusManager(document.addEventListener);
462+
/**
463+
* Returns the page-global FocusManager.
464+
*
465+
* The returned instance is guaranteed to not change across function calls, but
466+
* may change across page loads.
467+
*/
468+
static getFocusManager(): FocusManager {
469+
if (!FocusManager.focusManager) {
470+
FocusManager.focusManager = new FocusManager(document.addEventListener);
471+
}
472+
return FocusManager.focusManager;
457473
}
458-
return focusManager;
474+
}
475+
476+
/** Convenience function for FocusManager.getFocusManager. */
477+
export function getFocusManager(): FocusManager {
478+
return FocusManager.getFocusManager();
459479
}

core/keyboard_nav/line_cursor.ts

Lines changed: 39 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,9 @@ import {BlockSvg} from '../block_svg.js';
1818
import * as common from '../common.js';
1919
import type {Connection} from '../connection.js';
2020
import {ConnectionType} from '../connection_type.js';
21-
import type {Abstract} from '../events/events_abstract.js';
22-
import {Click, ClickTarget} from '../events/events_click.js';
23-
import {EventType} from '../events/type.js';
24-
import * as eventUtils from '../events/utils.js';
2521
import type {Field} from '../field.js';
22+
import {getFocusManager} from '../focus_manager.js';
23+
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
2624
import * as registry from '../registry.js';
2725
import type {MarkerSvg} from '../renderers/common/marker_svg.js';
2826
import type {PathObject} from '../renderers/zelos/path_object.js';
@@ -70,23 +68,12 @@ export class LineCursor extends Marker {
7068
options?: Partial<CursorOptions>,
7169
) {
7270
super();
73-
// Bind changeListener to facilitate future disposal.
74-
this.changeListener = this.changeListener.bind(this);
75-
this.workspace.addChangeListener(this.changeListener);
7671
// Regularise options and apply defaults.
7772
this.options = {...defaultOptions, ...options};
7873

7974
this.isZelos = workspace.getRenderer() instanceof Renderer;
8075
}
8176

82-
/**
83-
* Clean up this cursor.
84-
*/
85-
dispose() {
86-
this.workspace.removeChangeListener(this.changeListener);
87-
super.dispose();
88-
}
89-
9077
/**
9178
* Moves the cursor to the next previous connection, next connection or block
9279
* in the pre order traversal. Finds the next node in the pre order traversal.
@@ -331,8 +318,8 @@ export class LineCursor extends Marker {
331318
* @param node The current position in the AST.
332319
* @param isValid A function true/false depending on whether the given node
333320
* should be traversed.
334-
* @param loop Whether to loop around to the beginning of the workspace if
335-
* novalid node was found.
321+
* @param loop Whether to loop around to the beginning of the workspace if no
322+
* valid node was found.
336323
* @returns The next node in the traversal.
337324
*/
338325
getNextNode(
@@ -385,8 +372,8 @@ export class LineCursor extends Marker {
385372
* @param node The current position in the AST.
386373
* @param isValid A function true/false depending on whether the given node
387374
* should be traversed.
388-
* @param loop Whether to loop around to the end of the workspace if no
389-
* valid node was found.
375+
* @param loop Whether to loop around to the end of the workspace if no valid
376+
* node was found.
390377
* @returns The previous node in the traversal or null if no previous node
391378
* exists.
392379
*/
@@ -527,7 +514,12 @@ export class LineCursor extends Marker {
527514
* @returns The current field, connection, or block the cursor is on.
528515
*/
529516
override getCurNode(): ASTNode | null {
530-
this.updateCurNodeFromSelection();
517+
if (!this.updateCurNodeFromFocus()) {
518+
// Fall back to selection if focus fails to sync. This can happen for
519+
// non-focusable nodes or for cases when focus may not properly propagate
520+
// (such as for mouse clicks).
521+
this.updateCurNodeFromSelection();
522+
}
531523
return super.getCurNode();
532524
}
533525

@@ -572,16 +564,15 @@ export class LineCursor extends Marker {
572564
* this.drawMarker() instead of this.drawer.draw() directly.
573565
*
574566
* @param newNode The new location of the cursor.
575-
* @param updateSelection If true (the default) we'll update the selection
576-
* too.
577567
*/
578-
override setCurNode(newNode: ASTNode | null, updateSelection = true) {
579-
if (updateSelection) {
580-
this.updateSelectionFromNode(newNode);
581-
}
582-
568+
override setCurNode(newNode: ASTNode | null) {
583569
super.setCurNode(newNode);
584570

571+
const newNodeLocation = newNode?.getLocation();
572+
if (isFocusableNode(newNodeLocation)) {
573+
getFocusManager().focusNode(newNodeLocation);
574+
}
575+
585576
// Try to scroll cursor into view.
586577
if (newNode?.getType() === ASTNode.types.BLOCK) {
587578
const block = newNode.getLocation() as BlockSvg;
@@ -709,32 +700,6 @@ export class LineCursor extends Marker {
709700
}
710701
}
711702

712-
/**
713-
* Event listener that syncs the cursor location to the selected block on
714-
* SELECTED events.
715-
*
716-
* This does not run early enough in all cases so `getCurNode()` also updates
717-
* the node from the selection.
718-
*
719-
* @param event The `Selected` event.
720-
*/
721-
private changeListener(event: Abstract) {
722-
switch (event.type) {
723-
case EventType.SELECTED:
724-
this.updateCurNodeFromSelection();
725-
break;
726-
case EventType.CLICK: {
727-
const click = event as Click;
728-
if (
729-
click.workspaceId === this.workspace.id &&
730-
click.targetType === ClickTarget.WORKSPACE
731-
) {
732-
this.setCurNode(null);
733-
}
734-
}
735-
}
736-
}
737-
738703
/**
739704
* Updates the current node to match the selection.
740705
*
@@ -749,7 +714,7 @@ export class LineCursor extends Marker {
749714
const selected = common.getSelected();
750715

751716
if (selected === null && curNode?.getType() === ASTNode.types.BLOCK) {
752-
this.setCurNode(null, false);
717+
this.setCurNode(null);
753718
return;
754719
}
755720
if (selected?.workspace !== this.workspace) {
@@ -770,36 +735,35 @@ export class LineCursor extends Marker {
770735
block = block.getParent();
771736
}
772737
if (block) {
773-
this.setCurNode(ASTNode.createBlockNode(block), false);
738+
this.setCurNode(ASTNode.createBlockNode(block));
774739
}
775740
}
776741
}
777742

778743
/**
779-
* Updates the selection from the node.
744+
* Updates the current node to match what's currently focused.
780745
*
781-
* Clears the selection for non-block nodes.
782-
* Clears the selection for shadow blocks as the selection is drawn on
783-
* the parent but the cursor will be drawn on the shadow block itself.
784-
* We need to take care not to later clear the current node due to that null
785-
* selection, so we track the latest selection we're in sync with.
786-
*
787-
* @param newNode The new node.
746+
* @returns Whether the current node has been set successfully from the
747+
* current focused node.
788748
*/
789-
private updateSelectionFromNode(newNode: ASTNode | null) {
790-
if (newNode?.getType() === ASTNode.types.BLOCK) {
791-
if (common.getSelected() !== newNode.getLocation()) {
792-
eventUtils.disable();
793-
common.setSelected(newNode.getLocation() as BlockSvg);
794-
eventUtils.enable();
795-
}
796-
} else {
797-
if (common.getSelected()) {
798-
eventUtils.disable();
799-
common.setSelected(null);
800-
eventUtils.enable();
749+
private updateCurNodeFromFocus(): boolean {
750+
const focused = getFocusManager().getFocusedNode();
751+
752+
if (focused instanceof BlockSvg) {
753+
const block: BlockSvg | null = focused;
754+
if (block && block.workspace === this.workspace) {
755+
if (block.getRootBlock() === block && this.workspace.isFlyout) {
756+
// This block actually represents a stack. Note that this is needed
757+
// because ASTNode special cases stack for cross-block navigation.
758+
this.setCurNode(ASTNode.createStackNode(block));
759+
} else {
760+
this.setCurNode(ASTNode.createBlockNode(block));
761+
}
762+
return true;
801763
}
802764
}
765+
766+
return false;
803767
}
804768

805769
/**

tests/mocha/cursor_test.js

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

77
import {ASTNode} from '../../build/src/core/keyboard_nav/ast_node.js';
88
import {assert} from '../../node_modules/chai/chai.js';
9+
import {createRenderedBlock} from './test_helpers/block_definitions.js';
910
import {
1011
sharedTestSetup,
1112
sharedTestTeardown,
@@ -63,11 +64,11 @@ suite('Cursor', function () {
6364
]);
6465
this.workspace = Blockly.inject('blocklyDiv', {});
6566
this.cursor = this.workspace.getCursor();
66-
const blockA = this.workspace.newBlock('input_statement');
67-
const blockB = this.workspace.newBlock('input_statement');
68-
const blockC = this.workspace.newBlock('input_statement');
69-
const blockD = this.workspace.newBlock('input_statement');
70-
const blockE = this.workspace.newBlock('field_input');
67+
const blockA = createRenderedBlock(this.workspace, 'input_statement');
68+
const blockB = createRenderedBlock(this.workspace, 'input_statement');
69+
const blockC = createRenderedBlock(this.workspace, 'input_statement');
70+
const blockD = createRenderedBlock(this.workspace, 'input_statement');
71+
const blockE = createRenderedBlock(this.workspace, 'field_input');
7172

7273
blockA.nextConnection.connect(blockB.previousConnection);
7374
blockA.inputList[0].connection.connect(blockE.outputConnection);
@@ -105,12 +106,12 @@ suite('Cursor', function () {
105106
);
106107
});
107108

108-
test('In - From output connection', function () {
109+
test('In - From attached input connection', function () {
109110
const fieldBlock = this.blocks.E;
110-
const outputNode = ASTNode.createConnectionNode(
111-
fieldBlock.outputConnection,
111+
const inputConnectionNode = ASTNode.createConnectionNode(
112+
this.blocks.A.inputList[0].connection,
112113
);
113-
this.cursor.setCurNode(outputNode);
114+
this.cursor.setCurNode(inputConnectionNode);
114115
this.cursor.in();
115116
const curNode = this.cursor.getCurNode();
116117
assert.equal(curNode.getLocation(), fieldBlock);

0 commit comments

Comments
 (0)