Skip to content

Commit 3cbca8e

Browse files
authored
feat: Automatically manage focus tree tab indexes (#9079)
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #8965 Fixes #8978 Fixes #8970 Fixes google/blockly-keyboard-experimentation#523 Fixes google/blockly-keyboard-experimentation#547 Fixes part of #8910 ### Proposed Changes Fives groups of changes are included in this PR: 1. Support for automatic tab index management for focusable trees. 2. Support for automatic tab index management for focusable nodes. 3. Support for automatically hiding the flyout when back navigating from the toolbox. 4. A fix for `FocusManager` losing DOM syncing that was introduced in #9082. 5. Some cleanups for flyout and some tests for previous behavior changes to `FocusManager`. ### Reason for Changes Infrastructure changes reasoning: - Automatically managing tab indexes for both focusable trees and roots can largely reduce the difficulty of providing focusable nodes/trees and generally interacting with `FocusManager`. This facilitates a more automated navigation experience. - The fix for losing DOM syncing is possibly not reliable, but there are at least now tests to cover for it. This may be a case where a `try{} finally{}` could be warranted, but the code will stay as-is unless requested otherwise. `Flyout` changes: - `Flyout` no longer needs to be a focusable tree, but removing that would be an API breakage. Instead, it throws for most of the normal tree/node calls as it should no longer be used as such. Instead, its workspace has been made top-level tabbable (in addition to the main workspace) which solves the extra tab stop issues and general confusing inconsistencies between the flyout, toolbox, and workspace. - `Flyout` now correctly auto-selects the first block (#9103 notwithstanding). Technically it did before, however the extra `Flyout` tabstop before its workspace caused the inconsistency (since focusing the `Flyout` itself did not auto-select, only selecting its workspace did). Important caveats: - `getAttribute` is used in place of directly fetching `.tabIndex` since the latter can apparently default to `-1` (and possibly `0`) in cases when it's not actually set. This is a very surprising behavior that leads to incorrect test results. - Sometimes tab index still needs to be introduced (such as in cases where native DOM focus is needed, e.g. via `focus()` calls or clicking). This is demonstrated both by updates to `FocusManager`'s tests as well as toolbox's category and separator. This can be slightly tricky to miss as large parts of Blockly now depend on focus to represent their state, so clicking either needs to be managed by Blockly (with corresponding `focusNode` calls) or automatic (with a tab index defined for the element that can be clicked, or which has a child that can be clicked). Note that nearly all elements used for testing focus in the test `index.html` page have had their tab indexes removed to lean on `FocusManager`'s automatic tab management (though as mentioned above there is still some manual tab index management required for `focus()`-specific tests). ### Test Coverage New tests were added for all of the updated behaviors to `FocusManager`, including a new need to explicitly provide (and reset) tab indexes for all `focus()`-esque tests. This also includes adding new tests for some behaviors introduced in past PRs (a la #8910). Note that all of the new and affected conditionals in `FocusManager` have been verified as having at least 1 test that breaks when it's removed (inverted conditions weren't thoroughly tested, but it's expected that they should also be well covered now). Additional tests to cover the actual navigation flows will be added to the keyboard experimentation plugin repository as part of google/blockly-keyboard-experimentation#557 (this PR needs to be merged first). For manual testing, I mainly verified keyboard navigation with some cursory mouse & click testing in the simple playground. @rachel-fenichel also performed more thorough mouse & click testing (that yielded an actual issue that was fixed--see discussion below). The core webdriver tests have been verified to have seemingly the same existing failures with and without these changes. All of the following new keyboard navigation plugin tests have been verified as failing without the fixes introduced in this branch (and passing with them): - `Tab navigating to flyout should auto-select first block` - `Keyboard nav to different toolbox category should auto-select first block` - `Keyboard nav to different toolbox category and block should select different block` - `Tab navigate away from toolbox restores focus to initial element` - `Tab navigate away from toolbox closes flyout` - `Tab navigate away from flyout to toolbox and away closes flyout` - `Tabbing to the workspace after selecting flyout block should close the flyout` - `Tabbing to the workspace after selecting flyout block via workspace toolbox shortcut should close the flyout` - `Tabbing back from workspace should reopen the flyout` - `Navigation position in workspace should be retained when tabbing to flyout and back` - `Clicking outside Blockly with focused toolbox closes the flyout` - `Clicking outside Blockly with focused flyout closes the flyout` - `Clicking on toolbox category focuses it and opens flyout` ### Documentation No documentation changes are needed beyond the code doc changes included in the PR. ### Additional Information An additional PR will be introduced for the keyboard experimentation plugin repository to add tests there (see test coverage above). This description will be updated with a link to that PR once it exists.
1 parent fd0c08e commit 3cbca8e

File tree

15 files changed

+925
-159
lines changed

15 files changed

+925
-159
lines changed

core/bubbles/bubble.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ export abstract class Bubble implements IBubble, ISelectable {
9898
* when automatically positioning.
9999
* @param overriddenFocusableElement An optional replacement to the focusable
100100
* element that's represented by this bubble (as a focusable node). This
101-
* element will have its ID and tabindex overwritten. If not provided, the
102-
* focusable element of this node will default to the bubble's SVG root.
101+
* element will have its ID overwritten. If not provided, the focusable
102+
* element of this node will default to the bubble's SVG root.
103103
*/
104104
constructor(
105105
public readonly workspace: WorkspaceSvg,
@@ -138,7 +138,6 @@ export abstract class Bubble implements IBubble, ISelectable {
138138

139139
this.focusableElement = overriddenFocusableElement ?? this.svgRoot;
140140
this.focusableElement.setAttribute('id', this.id);
141-
this.focusableElement.setAttribute('tabindex', '-1');
142141

143142
browserEvents.conditionalBind(
144143
this.background,

core/comments/rendered_workspace_comment.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ export class RenderedWorkspaceComment
6565
this.view.setEditable(this.isEditable());
6666
this.view.getSvgRoot().setAttribute('data-id', this.id);
6767
this.view.getSvgRoot().setAttribute('id', this.id);
68-
this.view.getSvgRoot().setAttribute('tabindex', '-1');
6968

7069
this.addModelUpdateBindings();
7170

core/field.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,6 @@ export abstract class Field<T = any>
312312
const id = this.id_;
313313
if (!id) throw new Error('Expected ID to be defined prior to init.');
314314
this.fieldGroup_ = dom.createSvgElement(Svg.G, {
315-
'tabindex': '-1',
316315
'id': id,
317316
});
318317
if (!this.isVisible()) {

core/flyout_base.ts

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {FlyoutItem} from './flyout_item.js';
2222
import {FlyoutMetricsManager} from './flyout_metrics_manager.js';
2323
import {FlyoutNavigator} from './flyout_navigator.js';
2424
import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js';
25-
import {getFocusManager} from './focus_manager.js';
2625
import {IAutoHideable} from './interfaces/i_autohideable.js';
2726
import type {IFlyout} from './interfaces/i_flyout.js';
2827
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
@@ -308,7 +307,6 @@ export abstract class Flyout
308307
// hide/show code will set up proper visibility and size later.
309308
this.svgGroup_ = dom.createSvgElement(tagName, {
310309
'class': 'blocklyFlyout',
311-
'tabindex': '0',
312310
});
313311
this.svgGroup_.style.display = 'none';
314312
this.svgBackground_ = dom.createSvgElement(
@@ -324,8 +322,6 @@ export abstract class Flyout
324322
.getThemeManager()
325323
.subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity');
326324

327-
getFocusManager().registerTree(this);
328-
329325
return this.svgGroup_;
330326
}
331327

@@ -407,7 +403,6 @@ export abstract class Flyout
407403
if (this.svgGroup_) {
408404
dom.removeNode(this.svgGroup_);
409405
}
410-
getFocusManager().unregisterTree(this);
411406
}
412407

413408
/**
@@ -971,15 +966,22 @@ export abstract class Flyout
971966
return null;
972967
}
973968

974-
/** See IFocusableNode.getFocusableElement. */
969+
/**
970+
* See IFocusableNode.getFocusableElement.
971+
*
972+
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
973+
*/
975974
getFocusableElement(): HTMLElement | SVGElement {
976-
if (!this.svgGroup_) throw new Error('Flyout DOM is not yet created.');
977-
return this.svgGroup_;
975+
throw new Error('Flyouts are not directly focusable.');
978976
}
979977

980-
/** See IFocusableNode.getFocusableTree. */
978+
/**
979+
* See IFocusableNode.getFocusableTree.
980+
*
981+
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
982+
*/
981983
getFocusableTree(): IFocusableTree {
982-
return this;
984+
throw new Error('Flyouts are not directly focusable.');
983985
}
984986

985987
/** See IFocusableNode.onNodeFocus. */
@@ -990,31 +992,45 @@ export abstract class Flyout
990992

991993
/** See IFocusableNode.canBeFocused. */
992994
canBeFocused(): boolean {
993-
return true;
995+
return false;
994996
}
995997

996-
/** See IFocusableTree.getRootFocusableNode. */
998+
/**
999+
* See IFocusableNode.getRootFocusableNode.
1000+
*
1001+
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
1002+
*/
9971003
getRootFocusableNode(): IFocusableNode {
998-
return this;
1004+
throw new Error('Flyouts are not directly focusable.');
9991005
}
10001006

1001-
/** See IFocusableTree.getRestoredFocusableNode. */
1007+
/**
1008+
* See IFocusableNode.getRestoredFocusableNode.
1009+
*
1010+
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
1011+
*/
10021012
getRestoredFocusableNode(
10031013
_previousNode: IFocusableNode | null,
10041014
): IFocusableNode | null {
1005-
return null;
1015+
throw new Error('Flyouts are not directly focusable.');
10061016
}
10071017

1008-
/** See IFocusableTree.getNestedTrees. */
1018+
/**
1019+
* See IFocusableNode.getNestedTrees.
1020+
*
1021+
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
1022+
*/
10091023
getNestedTrees(): Array<IFocusableTree> {
1010-
return [this.workspace_];
1024+
throw new Error('Flyouts are not directly focusable.');
10111025
}
10121026

1013-
/** See IFocusableTree.lookUpFocusableNode. */
1027+
/**
1028+
* See IFocusableNode.lookUpFocusableNode.
1029+
*
1030+
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
1031+
*/
10141032
lookUpFocusableNode(_id: string): IFocusableNode | null {
1015-
// No focusable node needs to be returned since the flyout's subtree is a
1016-
// workspace that will manage its own focusable state.
1017-
return null;
1033+
throw new Error('Flyouts are not directly focusable.');
10181034
}
10191035

10201036
/** See IFocusableTree.onTreeFocus. */
@@ -1023,15 +1039,12 @@ export abstract class Flyout
10231039
_previousTree: IFocusableTree | null,
10241040
): void {}
10251041

1026-
/** See IFocusableTree.onTreeBlur. */
1027-
onTreeBlur(nextTree: IFocusableTree | null): void {
1028-
const toolbox = this.targetWorkspace.getToolbox();
1029-
// If focus is moving to either the toolbox or the flyout's workspace, do
1030-
// not close the flyout. For anything else, do close it since the flyout is
1031-
// no longer focused.
1032-
if (toolbox && nextTree === toolbox) return;
1033-
if (nextTree === this.workspace_) return;
1034-
if (toolbox) toolbox.clearSelection();
1035-
this.autoHide(false);
1042+
/**
1043+
* See IFocusableNode.onTreeBlur.
1044+
*
1045+
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
1046+
*/
1047+
onTreeBlur(_nextTree: IFocusableTree | null): void {
1048+
throw new Error('Flyouts are not directly focusable.');
10361049
}
10371050
}

core/flyout_button.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export class FlyoutButton
113113
this.id = idGenerator.getNextUniqueId();
114114
this.svgGroup = dom.createSvgElement(
115115
Svg.G,
116-
{'id': this.id, 'class': cssClass, 'tabindex': '-1'},
116+
{'id': this.id, 'class': cssClass},
117117
this.workspace.getCanvas(),
118118
);
119119

0 commit comments

Comments
 (0)