Skip to content

Commit bbd97ea

Browse files
authored
fix: Synchronize gestures and focus (#8981)
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #8952 Fixes #8950 Fixes #8971 Fixes a bunch of other keyboard + mouse synchronization issues found during the testing discussed in RaspberryPiFoundation/blockly-keyboard-experimentation#482 (comment). ### Proposed Changes This introduces a number of changes to: - Ensure that gestures which change selection state also change focus. - Ensure that ephemeral focus is more robust against certain classes of failures. ### Reason for Changes There are some ephemeral focus issues that can come up with certain actions (like clicking or dragging) don't properly change focus. Beyond that, some users will likely mix clicking and keyboard navigation, so it's essential that focus and selection state stay in sync when switching between these two types of navigation modalities. Other changes: - Drop-down div was actually incorrectly releasing ephemeral focus before animated closes finish which could reset focus afterwards, breaking focus state. - Both drop-down and widget divs have been updated to only return focus _after_ marking the workspace as focused since the last focused node should always be the thing returned. - In a number of gesture cases selection has been removed. This is due to `BlockSvg` self-managing its selection state based on focus, so focusing is sufficient. I've also centralized some of the focusing calls (such as putting one in `bringBlockToFront` to ensure focusing happens after potential DOM changes). - Similarly, `BlockSvg`'s own `bringToFront` has been updated to automatically restore focus after the operation completes. Since `bringToFront` can always result in focus loss, this provides robustness to ensure focus is restored. - Block pasting now ensures that focus is properly set, however a delay is needed due to additional rendering changes that need to complete (I didn't dig deeply into the _why_ of this). - Dragging has been updated to always focus the moved block if it's not in the process of being deleted. - There was some selection resetting logic removed from gesture's `doWorkspaceClick` function. As far as I can tell, this won't be needed anymore since blocks self-regulate their selection state now. - `FocusManager` has a new extra check for ephemeral focus to catch a specific class of failure where the browser takes away focus immediately after it's returned. This can happen if there are delay timing situations (like animations) which result in a focused node being deleted (which then results in the document body receiving focus). The robustness check is possibly not needed, but it help discover the animation issue with drop-down div and shows some promise for helping to fix the variables-closing-flyout problem. Some caveats: - Some undo/redo steps can still result in focus being lost. This may introduce some regressions for selection state, and definitely introduces some annoyances with keyboard navigation. More work will be needed to understand how to better redirect focus (and to what) in cases when blocks disappear. - There are many other places where focus is being forced or selection state being overwritten that could, in theory, cause issues with focus state. These may need to be fixed in the future. - There's a lot of redundancy with `focusNode` being called more than it needs to be. `FocusManager` does avoid calling `focus()` more than once for the same node, but it's possible for focus state to switch between multiple nodes or elements even for a single gesture (for example, due to bringing the block to the front causing a DOM refresh). While the eventual consistency nature of the manager means this isn't a real problem, it may cause problems with screen reader output in the future and warrant another pass at reducing `focusNode` calls (particularly for gestures and the click event pipeline). ### Test Coverage This PR is largely relying on existing tests for regression verification, though no new tests have been added for the specific regression cases. #8910 is tracking improving `FocusManager` tests which could include some cases for the new ephemeral focus improvements. #8915 is tracking general accessibility testing which could include adding tests for these specific regression cases. ### Documentation No new documentation is expected to be needed here. ### Additional Information These changes originate from both #8875 and from a branch @rachel-fenichel created to investigate some of the failures this PR addresses. These changes have also been verified against both Core's playground and the keyboard navigation plugin's test environment.
1 parent c18c7ff commit bbd97ea

File tree

7 files changed

+94
-30
lines changed

7 files changed

+94
-30
lines changed

core/block_svg.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import type {BlockMove} from './events/events_block_move.js';
3434
import {EventType} from './events/type.js';
3535
import * as eventUtils from './events/utils.js';
3636
import {FieldLabel} from './field_label.js';
37+
import {getFocusManager} from './focus_manager.js';
3738
import {IconType} from './icons/icon_types.js';
3839
import {MutatorIcon} from './icons/mutator_icon.js';
3940
import {WarningIcon} from './icons/warning_icon.js';
@@ -1290,6 +1291,7 @@ export class BlockSvg
12901291
* adjusting its parents.
12911292
*/
12921293
bringToFront(blockOnly = false) {
1294+
const previouslyFocused = getFocusManager().getFocusedNode();
12931295
/* eslint-disable-next-line @typescript-eslint/no-this-alias */
12941296
let block: this | null = this;
12951297
if (block.isDeadOrDying()) {
@@ -1306,6 +1308,13 @@ export class BlockSvg
13061308
if (blockOnly) break;
13071309
block = block.getParent();
13081310
} while (block);
1311+
if (previouslyFocused) {
1312+
// Bringing a block to the front of the stack doesn't fundamentally change
1313+
// the logical structure of the page, but it does change element ordering
1314+
// which can take automatically take away focus from a node. Ensure focus
1315+
// is restored to avoid a discontinuity.
1316+
getFocusManager().focusNode(previouslyFocused);
1317+
}
13091318
}
13101319

13111320
/**

core/clipboard/block_paster.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
*/
66

77
import {BlockSvg} from '../block_svg.js';
8-
import * as common from '../common.js';
8+
import {IFocusableNode} from '../blockly.js';
99
import {config} from '../config.js';
1010
import {EventType} from '../events/type.js';
1111
import * as eventUtils from '../events/utils.js';
12+
import {getFocusManager} from '../focus_manager.js';
1213
import {ICopyData} from '../interfaces/i_copyable.js';
1314
import {IPaster} from '../interfaces/i_paster.js';
15+
import * as renderManagement from '../render_management.js';
1416
import {State, append} from '../serialization/blocks.js';
1517
import {Coordinate} from '../utils/coordinate.js';
1618
import {WorkspaceSvg} from '../workspace_svg.js';
@@ -55,7 +57,13 @@ export class BlockPaster implements IPaster<BlockCopyData, BlockSvg> {
5557
if (eventUtils.isEnabled() && !block.isShadow()) {
5658
eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(block));
5759
}
58-
common.setSelected(block);
60+
61+
// Sometimes there's a delay before the block is fully created and ready for
62+
// focusing, so wait slightly before focusing the newly pasted block.
63+
const nodeToFocus: IFocusableNode = block;
64+
renderManagement
65+
.finishQueuedRenders()
66+
.then(() => getFocusManager().focusNode(nodeToFocus));
5967
return block;
6068
}
6169
}

core/dragging/dragger.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import * as blockAnimations from '../block_animations.js';
88
import {BlockSvg} from '../block_svg.js';
99
import {ComponentManager} from '../component_manager.js';
1010
import * as eventUtils from '../events/utils.js';
11+
import {getFocusManager} from '../focus_manager.js';
1112
import {IDeletable, isDeletable} from '../interfaces/i_deletable.js';
1213
import {IDeleteArea} from '../interfaces/i_delete_area.js';
1314
import {IDragTarget} from '../interfaces/i_drag_target.js';
1415
import {IDraggable} from '../interfaces/i_draggable.js';
1516
import {IDragger} from '../interfaces/i_dragger.js';
17+
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
1618
import * as registry from '../registry.js';
1719
import {Coordinate} from '../utils/coordinate.js';
1820
import {WorkspaceSvg} from '../workspace_svg.js';
@@ -129,6 +131,12 @@ export class Dragger implements IDragger {
129131
root.dispose();
130132
}
131133
eventUtils.setGroup(false);
134+
135+
if (!wouldDelete && isFocusableNode(this.draggable)) {
136+
// Ensure focusable nodes that have finished dragging (but aren't being
137+
// deleted) end with focus and selection.
138+
getFocusManager().focusNode(this.draggable);
139+
}
132140
}
133141

134142
// We need to special case blocks for now so that we look at the root block

core/dropdowndiv.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -629,10 +629,6 @@ export function hide() {
629629
animateOutTimer = setTimeout(function () {
630630
hideWithoutAnimation();
631631
}, ANIMATION_TIME * 1000);
632-
if (returnEphemeralFocus) {
633-
returnEphemeralFocus();
634-
returnEphemeralFocus = null;
635-
}
636632
if (onHide) {
637633
onHide();
638634
onHide = null;
@@ -648,10 +644,6 @@ export function hideWithoutAnimation() {
648644
clearTimeout(animateOutTimer);
649645
}
650646

651-
if (returnEphemeralFocus) {
652-
returnEphemeralFocus();
653-
returnEphemeralFocus = null;
654-
}
655647
if (onHide) {
656648
onHide();
657649
onHide = null;
@@ -660,6 +652,11 @@ export function hideWithoutAnimation() {
660652
owner = null;
661653

662654
(common.getMainWorkspace() as WorkspaceSvg).markFocused();
655+
656+
if (returnEphemeralFocus) {
657+
returnEphemeralFocus();
658+
returnEphemeralFocus = null;
659+
}
663660
}
664661

665662
/**

core/focus_manager.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,20 @@ export class FocusManager {
5656
*/
5757
static readonly PASSIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyPassiveFocus';
5858

59-
focusedNode: IFocusableNode | null = null;
60-
registeredTrees: Array<IFocusableTree> = [];
59+
private focusedNode: IFocusableNode | null = null;
60+
private previouslyFocusedNode: IFocusableNode | null = null;
61+
private registeredTrees: Array<IFocusableTree> = [];
6162

6263
private currentlyHoldsEphemeralFocus: boolean = false;
6364
private lockFocusStateChanges: boolean = false;
65+
private recentlyLostAllFocus: boolean = false;
6466

6567
constructor(
6668
addGlobalEventListener: (type: string, listener: EventListener) => void,
6769
) {
6870
// Note that 'element' here is the element *gaining* focus.
6971
const maybeFocus = (element: Element | EventTarget | null) => {
72+
this.recentlyLostAllFocus = !element;
7073
let newNode: IFocusableNode | null | undefined = null;
7174
if (element instanceof HTMLElement || element instanceof SVGElement) {
7275
// If the target losing or gaining focus maps to any tree, then it
@@ -164,7 +167,7 @@ export class FocusManager {
164167
const root = tree.getRootFocusableNode();
165168
if (focusedNode) this.removeHighlight(focusedNode);
166169
if (this.focusedNode === focusedNode || this.focusedNode === root) {
167-
this.focusedNode = null;
170+
this.updateFocusedNode(null);
168171
}
169172
this.removeHighlight(root);
170173
}
@@ -277,7 +280,7 @@ export class FocusManager {
277280
// Only change the actively focused node if ephemeral state isn't held.
278281
this.activelyFocusNode(focusableNode, prevTree ?? null);
279282
}
280-
this.focusedNode = focusableNode;
283+
this.updateFocusedNode(focusableNode);
281284
}
282285

283286
/**
@@ -328,6 +331,22 @@ export class FocusManager {
328331

329332
if (this.focusedNode) {
330333
this.activelyFocusNode(this.focusedNode, null);
334+
335+
// Even though focus was restored, check if it's lost again. It's
336+
// possible for the browser to force focus away from all elements once
337+
// the ephemeral element disappears. This ensures focus is restored.
338+
const capturedNode = this.focusedNode;
339+
setTimeout(() => {
340+
// These checks are set up to minimize the risk that a legitimate
341+
// focus change occurred within the delay that this would override.
342+
if (
343+
!this.focusedNode &&
344+
this.previouslyFocusedNode === capturedNode &&
345+
this.recentlyLostAllFocus
346+
) {
347+
this.focusNode(capturedNode);
348+
}
349+
}, 0);
331350
}
332351
};
333352
}
@@ -348,6 +367,17 @@ export class FocusManager {
348367
}
349368
}
350369

370+
/**
371+
* Updates the internally tracked focused node to the specified node, or null
372+
* if focus is being lost. This also updates previous focus tracking.
373+
*
374+
* @param newFocusedNode The new node to set as focused.
375+
*/
376+
private updateFocusedNode(newFocusedNode: IFocusableNode | null) {
377+
this.previouslyFocusedNode = this.focusedNode;
378+
this.focusedNode = newFocusedNode;
379+
}
380+
351381
/**
352382
* Defocuses the current actively focused node tracked by the manager, iff
353383
* there's a node being tracked and the manager doesn't have ephemeral focus.
@@ -358,7 +388,7 @@ export class FocusManager {
358388
// restored upon exiting ephemeral focus mode.
359389
if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) {
360390
this.passivelyFocusNode(this.focusedNode, null);
361-
this.focusedNode = null;
391+
this.updateFocusedNode(null);
362392
}
363393
}
364394

core/gesture.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import * as dropDownDiv from './dropdowndiv.js';
2525
import {EventType} from './events/type.js';
2626
import * as eventUtils from './events/utils.js';
2727
import type {Field} from './field.js';
28+
import {getFocusManager} from './focus_manager.js';
2829
import type {IBubble} from './interfaces/i_bubble.js';
2930
import {IDraggable, isDraggable} from './interfaces/i_draggable.js';
3031
import {IDragger} from './interfaces/i_dragger.js';
@@ -289,7 +290,7 @@ export class Gesture {
289290
// The start block is no longer relevant, because this is a drag.
290291
this.startBlock = null;
291292
this.targetBlock = this.flyout.createBlock(this.targetBlock);
292-
common.setSelected(this.targetBlock);
293+
getFocusManager().focusNode(this.targetBlock);
293294
return true;
294295
}
295296
return false;
@@ -734,6 +735,7 @@ export class Gesture {
734735
this.startComment.showContextMenu(e);
735736
} else if (this.startWorkspace_ && !this.flyout) {
736737
this.startWorkspace_.hideChaff();
738+
getFocusManager().focusNode(this.startWorkspace_);
737739
this.startWorkspace_.showContextMenu(e);
738740
}
739741

@@ -762,9 +764,12 @@ export class Gesture {
762764
this.mostRecentEvent = e;
763765

764766
if (!this.startBlock && !this.startBubble && !this.startComment) {
765-
// Selection determines what things start drags. So to drag the workspace,
766-
// we need to deselect anything that was previously selected.
767-
common.setSelected(null);
767+
// Ensure the workspace is selected if nothing else should be. Note that
768+
// this is focusNode() instead of focusTree() because if any active node
769+
// is focused in the workspace it should be defocused.
770+
getFocusManager().focusNode(ws);
771+
} else if (this.startBlock) {
772+
getFocusManager().focusNode(this.startBlock);
768773
}
769774

770775
this.doStart(e);
@@ -865,13 +870,18 @@ export class Gesture {
865870
);
866871
}
867872

873+
// Note that the order is important here: bringing a block to the front will
874+
// cause it to become focused and showing the field editor will capture
875+
// focus ephemerally. It's important to ensure that focus is properly
876+
// restored back to the block after field editing has completed.
877+
this.bringBlockToFront();
878+
868879
// Only show the editor if the field's editor wasn't already open
869880
// right before this gesture started.
870881
const dropdownAlreadyOpen = this.currentDropdownOwner === this.startField;
871882
if (!dropdownAlreadyOpen) {
872883
this.startField.showEditor(this.mostRecentEvent);
873884
}
874-
this.bringBlockToFront();
875885
}
876886

877887
/** Execute an icon click. */
@@ -901,6 +911,9 @@ export class Gesture {
901911
const newBlock = this.flyout.createBlock(this.targetBlock);
902912
newBlock.snapToGrid();
903913
newBlock.bumpNeighbours();
914+
915+
// If a new block was added, make sure that it's correctly focused.
916+
getFocusManager().focusNode(newBlock);
904917
}
905918
} else {
906919
if (!this.startWorkspace_) {
@@ -928,11 +941,7 @@ export class Gesture {
928941
* @param _e A pointerup event.
929942
*/
930943
private doWorkspaceClick(_e: PointerEvent) {
931-
const ws = this.creatorWorkspace;
932-
if (common.getSelected()) {
933-
common.getSelected()!.unselect();
934-
}
935-
this.fireWorkspaceClick(this.startWorkspace_ || ws);
944+
this.fireWorkspaceClick(this.startWorkspace_ || this.creatorWorkspace);
936945
}
937946

938947
/* End functions defining what actions to take to execute clicks on each type
@@ -947,6 +956,8 @@ export class Gesture {
947956
private bringBlockToFront() {
948957
// Blocks in the flyout don't overlap, so skip the work.
949958
if (this.targetBlock && !this.flyout) {
959+
// Always ensure the block being dragged/clicked has focus.
960+
getFocusManager().focusNode(this.targetBlock);
950961
this.targetBlock.bringToFront();
951962
}
952963
}
@@ -1023,7 +1034,6 @@ export class Gesture {
10231034
// If the gesture already went through a bubble, don't set the start block.
10241035
if (!this.startBlock && !this.startBubble) {
10251036
this.startBlock = block;
1026-
common.setSelected(this.startBlock);
10271037
if (block.isInFlyout && block !== block.getRootBlock()) {
10281038
this.setTargetBlock(block.getRootBlock());
10291039
} else {
@@ -1046,6 +1056,7 @@ export class Gesture {
10461056
this.setTargetBlock(block.getParent()!);
10471057
} else {
10481058
this.targetBlock = block;
1059+
getFocusManager().focusNode(block);
10491060
}
10501061
}
10511062

core/widgetdiv.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,6 @@ export function hide() {
131131
div.style.display = 'none';
132132
div.style.left = '';
133133
div.style.top = '';
134-
if (returnEphemeralFocus) {
135-
returnEphemeralFocus();
136-
returnEphemeralFocus = null;
137-
}
138134
if (dispose) {
139135
dispose();
140136
dispose = null;
@@ -150,6 +146,11 @@ export function hide() {
150146
themeClassName = '';
151147
}
152148
(common.getMainWorkspace() as WorkspaceSvg).markFocused();
149+
150+
if (returnEphemeralFocus) {
151+
returnEphemeralFocus();
152+
returnEphemeralFocus = null;
153+
}
153154
}
154155

155156
/**

0 commit comments

Comments
 (0)