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 https://github.com/google/blockly-keyboard-experimentation/issues/523
Fixes https://github.com/google/blockly-keyboard-experimentation/issues/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 https://github.com/google/blockly-keyboard-experimentation/pull/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.
This commit is contained in:
Ben Henning
2025-05-29 12:09:59 -07:00
committed by GitHub
parent fd0c08e950
commit 3cbca8e4b6
15 changed files with 925 additions and 159 deletions

View File

@@ -98,8 +98,8 @@ export abstract class Bubble implements IBubble, ISelectable {
* when automatically positioning.
* @param overriddenFocusableElement An optional replacement to the focusable
* element that's represented by this bubble (as a focusable node). This
* element will have its ID and tabindex overwritten. If not provided, the
* focusable element of this node will default to the bubble's SVG root.
* element will have its ID overwritten. If not provided, the focusable
* element of this node will default to the bubble's SVG root.
*/
constructor(
public readonly workspace: WorkspaceSvg,
@@ -138,7 +138,6 @@ export abstract class Bubble implements IBubble, ISelectable {
this.focusableElement = overriddenFocusableElement ?? this.svgRoot;
this.focusableElement.setAttribute('id', this.id);
this.focusableElement.setAttribute('tabindex', '-1');
browserEvents.conditionalBind(
this.background,

View File

@@ -65,7 +65,6 @@ export class RenderedWorkspaceComment
this.view.setEditable(this.isEditable());
this.view.getSvgRoot().setAttribute('data-id', this.id);
this.view.getSvgRoot().setAttribute('id', this.id);
this.view.getSvgRoot().setAttribute('tabindex', '-1');
this.addModelUpdateBindings();

View File

@@ -312,7 +312,6 @@ export abstract class Field<T = any>
const id = this.id_;
if (!id) throw new Error('Expected ID to be defined prior to init.');
this.fieldGroup_ = dom.createSvgElement(Svg.G, {
'tabindex': '-1',
'id': id,
});
if (!this.isVisible()) {

View File

@@ -22,7 +22,6 @@ import {FlyoutItem} from './flyout_item.js';
import {FlyoutMetricsManager} from './flyout_metrics_manager.js';
import {FlyoutNavigator} from './flyout_navigator.js';
import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js';
import {getFocusManager} from './focus_manager.js';
import {IAutoHideable} from './interfaces/i_autohideable.js';
import type {IFlyout} from './interfaces/i_flyout.js';
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
@@ -308,7 +307,6 @@ export abstract class Flyout
// hide/show code will set up proper visibility and size later.
this.svgGroup_ = dom.createSvgElement(tagName, {
'class': 'blocklyFlyout',
'tabindex': '0',
});
this.svgGroup_.style.display = 'none';
this.svgBackground_ = dom.createSvgElement(
@@ -324,8 +322,6 @@ export abstract class Flyout
.getThemeManager()
.subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity');
getFocusManager().registerTree(this);
return this.svgGroup_;
}
@@ -407,7 +403,6 @@ export abstract class Flyout
if (this.svgGroup_) {
dom.removeNode(this.svgGroup_);
}
getFocusManager().unregisterTree(this);
}
/**
@@ -971,15 +966,22 @@ export abstract class Flyout
return null;
}
/** See IFocusableNode.getFocusableElement. */
/**
* See IFocusableNode.getFocusableElement.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
getFocusableElement(): HTMLElement | SVGElement {
if (!this.svgGroup_) throw new Error('Flyout DOM is not yet created.');
return this.svgGroup_;
throw new Error('Flyouts are not directly focusable.');
}
/** See IFocusableNode.getFocusableTree. */
/**
* See IFocusableNode.getFocusableTree.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
getFocusableTree(): IFocusableTree {
return this;
throw new Error('Flyouts are not directly focusable.');
}
/** See IFocusableNode.onNodeFocus. */
@@ -990,31 +992,45 @@ export abstract class Flyout
/** See IFocusableNode.canBeFocused. */
canBeFocused(): boolean {
return true;
return false;
}
/** See IFocusableTree.getRootFocusableNode. */
/**
* See IFocusableNode.getRootFocusableNode.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
getRootFocusableNode(): IFocusableNode {
return this;
throw new Error('Flyouts are not directly focusable.');
}
/** See IFocusableTree.getRestoredFocusableNode. */
/**
* See IFocusableNode.getRestoredFocusableNode.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
getRestoredFocusableNode(
_previousNode: IFocusableNode | null,
): IFocusableNode | null {
return null;
throw new Error('Flyouts are not directly focusable.');
}
/** See IFocusableTree.getNestedTrees. */
/**
* See IFocusableNode.getNestedTrees.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
getNestedTrees(): Array<IFocusableTree> {
return [this.workspace_];
throw new Error('Flyouts are not directly focusable.');
}
/** See IFocusableTree.lookUpFocusableNode. */
/**
* See IFocusableNode.lookUpFocusableNode.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
lookUpFocusableNode(_id: string): IFocusableNode | null {
// No focusable node needs to be returned since the flyout's subtree is a
// workspace that will manage its own focusable state.
return null;
throw new Error('Flyouts are not directly focusable.');
}
/** See IFocusableTree.onTreeFocus. */
@@ -1023,15 +1039,12 @@ export abstract class Flyout
_previousTree: IFocusableTree | null,
): void {}
/** See IFocusableTree.onTreeBlur. */
onTreeBlur(nextTree: IFocusableTree | null): void {
const toolbox = this.targetWorkspace.getToolbox();
// If focus is moving to either the toolbox or the flyout's workspace, do
// not close the flyout. For anything else, do close it since the flyout is
// no longer focused.
if (toolbox && nextTree === toolbox) return;
if (nextTree === this.workspace_) return;
if (toolbox) toolbox.clearSelection();
this.autoHide(false);
/**
* See IFocusableNode.onTreeBlur.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
onTreeBlur(_nextTree: IFocusableTree | null): void {
throw new Error('Flyouts are not directly focusable.');
}
}

View File

@@ -113,7 +113,7 @@ export class FlyoutButton
this.id = idGenerator.getNextUniqueId();
this.svgGroup = dom.createSvgElement(
Svg.G,
{'id': this.id, 'class': cssClass, 'tabindex': '-1'},
{'id': this.id, 'class': cssClass},
this.workspace.getCanvas(),
);

View File

@@ -17,6 +17,24 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
*/
export type ReturnEphemeralFocus = () => void;
/**
* Represents an IFocusableTree that has been registered for focus management in
* FocusManager.
*/
class TreeRegistration {
/**
* Constructs a new TreeRegistration.
*
* @param tree The tree being registered.
* @param rootShouldBeAutoTabbable Whether the tree should have automatic
* top-level tab management.
*/
constructor(
readonly tree: IFocusableTree,
readonly rootShouldBeAutoTabbable: boolean,
) {}
}
/**
* A per-page singleton that manages Blockly focus across one or more
* IFocusableTrees, and bidirectionally synchronizes this focus with the DOM.
@@ -58,7 +76,7 @@ export class FocusManager {
private focusedNode: IFocusableNode | null = null;
private previouslyFocusedNode: IFocusableNode | null = null;
private registeredTrees: Array<IFocusableTree> = [];
private registeredTrees: Array<TreeRegistration> = [];
private currentlyHoldsEphemeralFocus: boolean = false;
private lockFocusStateChanges: boolean = false;
@@ -79,7 +97,8 @@ export class FocusManager {
// If the target losing or gaining focus maps to any tree, then it
// should be updated. Per the contract of findFocusableNodeFor only one
// tree should claim the element, so the search can be exited early.
for (const tree of this.registeredTrees) {
for (const reg of this.registeredTrees) {
const tree = reg.tree;
newNode = FocusableTreeTraverser.findFocusableNodeFor(element, tree);
if (newNode) break;
}
@@ -132,13 +151,32 @@ export class FocusManager {
* This function throws if the provided tree is already currently registered
* in this manager. Use isRegistered to check in cases when it can't be
* certain whether the tree has been registered.
*
* The tree's registration can be customized to configure automatic tab stops.
* This specifically provides capability for the user to be able to tab
* navigate to the root of the tree but only when the tree doesn't hold active
* focus. If this functionality is disabled then the tree's root will
* automatically be made focusable (but not tabbable) when it is first focused
* in the same way as any other focusable node.
*
* @param tree The IFocusableTree to register.
* @param rootShouldBeAutoTabbable Whether the root of this tree should be
* added as a top-level page tab stop when it doesn't hold active focus.
*/
registerTree(tree: IFocusableTree): void {
registerTree(
tree: IFocusableTree,
rootShouldBeAutoTabbable: boolean = false,
): void {
this.ensureManagerIsUnlocked();
if (this.isRegistered(tree)) {
throw Error(`Attempted to re-register already registered tree: ${tree}.`);
}
this.registeredTrees.push(tree);
this.registeredTrees.push(
new TreeRegistration(tree, rootShouldBeAutoTabbable),
);
if (rootShouldBeAutoTabbable) {
tree.getRootFocusableNode().getFocusableElement().tabIndex = 0;
}
}
/**
@@ -147,7 +185,15 @@ export class FocusManager {
* unregisterTree.
*/
isRegistered(tree: IFocusableTree): boolean {
return this.registeredTrees.findIndex((reg) => reg === tree) !== -1;
return !!this.lookUpRegistration(tree);
}
/**
* Returns the TreeRegistration for the specified tree, or null if the tree is
* not currently registered.
*/
private lookUpRegistration(tree: IFocusableTree): TreeRegistration | null {
return this.registeredTrees.find((reg) => reg.tree === tree) ?? null;
}
/**
@@ -158,13 +204,19 @@ export class FocusManager {
*
* This function throws if the provided tree is not currently registered in
* this manager.
*
* This function will reset the tree's root element tabindex if the tree was
* registered with automatic tab management.
*/
unregisterTree(tree: IFocusableTree): void {
this.ensureManagerIsUnlocked();
if (!this.isRegistered(tree)) {
throw Error(`Attempted to unregister not registered tree: ${tree}.`);
}
const treeIndex = this.registeredTrees.findIndex((reg) => reg === tree);
const treeIndex = this.registeredTrees.findIndex(
(reg) => reg.tree === tree,
);
const registration = this.registeredTrees[treeIndex];
this.registeredTrees.splice(treeIndex, 1);
const focusedNode = FocusableTreeTraverser.findFocusedNode(tree);
@@ -174,6 +226,13 @@ export class FocusManager {
this.updateFocusedNode(null);
}
this.removeHighlight(root);
if (registration.rootShouldBeAutoTabbable) {
tree
.getRootFocusableNode()
.getFocusableElement()
.removeAttribute('tabindex');
}
}
/**
@@ -240,11 +299,15 @@ export class FocusManager {
* canBeFocused() method returns false), it will be ignored and any existing
* focus state will remain unchanged.
*
* Note that this may update the specified node's element's tabindex to ensure
* that it can be properly read out by screenreaders while focused.
*
* @param focusableNode The node that should receive active focus.
*/
focusNode(focusableNode: IFocusableNode): void {
this.ensureManagerIsUnlocked();
if (!this.currentlyHoldsEphemeralFocus) {
const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus;
if (mustRestoreUpdatingNode) {
// Disable state syncing from DOM events since possible calls to focus()
// below will loop a call back to focusNode().
this.isUpdatingFocusedNode = true;
@@ -258,12 +321,21 @@ export class FocusManager {
const prevFocusedElement = this.focusedNode?.getFocusableElement();
const hasDesyncedState = prevFocusedElement !== document.activeElement;
if (this.focusedNode === focusableNode && !hasDesyncedState) {
if (mustRestoreUpdatingNode) {
// Reenable state syncing from DOM events.
this.isUpdatingFocusedNode = false;
}
return; // State is unchanged.
}
if (!focusableNode.canBeFocused()) {
// This node can't be focused.
console.warn("Trying to focus a node that can't be focused.");
if (mustRestoreUpdatingNode) {
// Reenable state syncing from DOM events.
this.isUpdatingFocusedNode = false;
}
return;
}
@@ -312,7 +384,7 @@ export class FocusManager {
this.activelyFocusNode(nodeToFocus, prevTree ?? null);
}
this.updateFocusedNode(nodeToFocus);
if (!this.currentlyHoldsEphemeralFocus) {
if (mustRestoreUpdatingNode) {
// Reenable state syncing from DOM events.
this.isUpdatingFocusedNode = false;
}
@@ -448,14 +520,38 @@ export class FocusManager {
// node's focusable element (which *is* allowed to be invisible until the
// node needs to be focused).
this.lockFocusStateChanges = true;
if (node.getFocusableTree() !== prevTree) {
node.getFocusableTree().onTreeFocus(node, prevTree);
const tree = node.getFocusableTree();
const elem = node.getFocusableElement();
const nextTreeReg = this.lookUpRegistration(tree);
const treeIsTabManaged = nextTreeReg?.rootShouldBeAutoTabbable;
if (tree !== prevTree) {
tree.onTreeFocus(node, prevTree);
if (treeIsTabManaged) {
// If this node's tree has its tab auto-managed, ensure that it's no
// longer tabbable now that it holds active focus.
tree.getRootFocusableNode().getFocusableElement().tabIndex = -1;
}
}
node.onNodeFocus();
this.lockFocusStateChanges = false;
// The tab index should be set in all cases where:
// - It doesn't overwrite an pre-set tab index for the node.
// - The node is part of a tree whose tab index is unmanaged.
// OR
// - The node is part of a managed tree but this isn't the root. Managed
// roots are ignored since they are always overwritten to have a tab index
// of -1 with active focus so that they cannot be tab navigated.
//
// Setting the tab index ensures that the node's focusable element can
// actually receive DOM focus.
if (!treeIsTabManaged || node !== tree.getRootFocusableNode()) {
if (!elem.hasAttribute('tabindex')) elem.tabIndex = -1;
}
this.setNodeToVisualActiveFocus(node);
node.getFocusableElement().focus();
elem.focus();
}
/**
@@ -475,13 +571,21 @@ export class FocusManager {
nextTree: IFocusableTree | null,
): void {
this.lockFocusStateChanges = true;
if (node.getFocusableTree() !== nextTree) {
node.getFocusableTree().onTreeBlur(nextTree);
const tree = node.getFocusableTree();
if (tree !== nextTree) {
tree.onTreeBlur(nextTree);
const reg = this.lookUpRegistration(tree);
if (reg?.rootShouldBeAutoTabbable) {
// If this node's tree has its tab auto-managed, ensure that it's now
// tabbable since it no longer holds active focus.
tree.getRootFocusableNode().getFocusableElement().tabIndex = 0;
}
}
node.onNodeBlur();
this.lockFocusStateChanges = false;
if (node.getFocusableTree() !== nextTree) {
if (tree !== nextTree) {
this.setNodeToVisualPassiveFocus(node);
}
}

View File

@@ -59,7 +59,6 @@ export abstract class Icon implements IIcon {
const svgBlock = this.sourceBlock as BlockSvg;
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyIconGroup',
'tabindex': '-1',
'id': this.id,
});
svgBlock.getSvgRoot().appendChild(this.svgRoot);

View File

@@ -19,13 +19,11 @@ export interface IFocusableNode {
* - blocklyActiveFocus
* - blocklyPassiveFocus
*
* The returned element must also have a valid ID specified, and unique across
* the entire page. Failing to have a properly unique ID could result in
* trying to focus one node (such as via a mouse click) leading to another
* node with the same ID actually becoming focused by FocusManager. The
* returned element must also have a negative tabindex (since the focus
* manager itself will manage its tab index and a tab index must be present in
* order for the element to be focusable in the DOM).
* The returned element must also have a valid ID specified, and this ID
* should be unique across the entire page. Failing to have a properly unique
* ID could result in trying to focus one node (such as via a mouse click)
* leading to another node with the same ID actually becoming focused by
* FocusManager.
*
* The returned element must be visible if the node is ever focused via
* FocusManager.focusNode() or FocusManager.focusTree(). It's allowed for an
@@ -34,7 +32,11 @@ export interface IFocusableNode {
*
* It's expected the actual returned element will not change for the lifetime
* of the node (that is, its properties can change but a new element should
* never be returned).
* never be returned). Also, the returned element will have its tabindex
* overwritten throughout the lifecycle of this node and FocusManager.
*
* If a node requires the ability to be focused directly without first being
* focused via FocusManager then it must set its own tab index.
*
* @returns The HTMLElement or SVGElement which can both receive focus and be
* visually represented as actively or passively focused for this node.

View File

@@ -50,7 +50,7 @@ export class PathObject implements IPathObject {
/** The primary path of the block. */
this.svgPath = dom.createSvgElement(
Svg.PATH,
{'class': 'blocklyPath', 'tabindex': '-1'},
{'class': 'blocklyPath'},
this.svgRoot,
);
@@ -239,7 +239,6 @@ export class PathObject implements IPathObject {
'id': connection.id,
'class': 'blocklyHighlightedConnectionPath',
'style': 'display: none;',
'tabindex': '-1',
'd': connectionPath,
'transform': transformation,
},

View File

@@ -225,6 +225,8 @@ export class ToolboxCategory
*/
protected createContainer_(): HTMLDivElement {
const container = document.createElement('div');
// Ensure that the category has a tab index to ensure it receives focus when
// clicked (since clicking isn't managed by the toolbox).
container.tabIndex = -1;
container.id = this.getId();
const className = this.cssConfig_['container'];

View File

@@ -54,6 +54,8 @@ export class ToolboxSeparator extends ToolboxItem {
*/
protected createDom_(): HTMLDivElement {
const container = document.createElement('div');
// Ensure that the separator has a tab index to ensure it receives focus
// when clicked (since clicking isn't managed by the toolbox).
container.tabIndex = -1;
container.id = this.getId();
const className = this.cssConfig_['container'];

View File

@@ -22,7 +22,10 @@ import '../events/events_toolbox_item_select.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import {getFocusManager} from '../focus_manager.js';
import type {IAutoHideable} from '../interfaces/i_autohideable.js';
import {
isAutoHideable,
type IAutoHideable,
} from '../interfaces/i_autohideable.js';
import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js';
import {isDeletable} from '../interfaces/i_deletable.js';
import type {IDraggable} from '../interfaces/i_draggable.js';
@@ -169,7 +172,7 @@ export class Toolbox
ComponentManager.Capability.DRAG_TARGET,
],
});
getFocusManager().registerTree(this);
getFocusManager().registerTree(this, true);
}
/**
@@ -200,7 +203,6 @@ export class Toolbox
*/
protected createContainer_(): HTMLDivElement {
const toolboxContainer = document.createElement('div');
toolboxContainer.tabIndex = 0;
toolboxContainer.setAttribute('layout', this.isHorizontal() ? 'h' : 'v');
dom.addClass(toolboxContainer, 'blocklyToolbox');
toolboxContainer.setAttribute('dir', this.RTL ? 'RTL' : 'LTR');
@@ -1142,7 +1144,16 @@ export class Toolbox
}
/** See IFocusableTree.onTreeBlur. */
onTreeBlur(_nextTree: IFocusableTree | null): void {}
onTreeBlur(nextTree: IFocusableTree | null): void {
// If navigating to anything other than the toolbox's flyout then clear the
// selection so that the toolbox's flyout can automatically close.
if (!nextTree || nextTree !== this.flyout?.getWorkspace()) {
this.clearSelection();
if (this.flyout && isAutoHideable(this.flyout)) {
this.flyout.autoHide(false);
}
}
}
}
/** CSS for Toolbox. See css.js for use. */

View File

@@ -762,8 +762,6 @@ export class WorkspaceSvg
*/
this.svgGroup_ = dom.createSvgElement(Svg.G, {
'class': 'blocklyWorkspace',
// Only the top-level workspace should be tabbable.
'tabindex': injectionDiv ? '0' : '-1',
'id': this.id,
});
if (injectionDiv) {
@@ -849,7 +847,8 @@ export class WorkspaceSvg
isParentWorkspace ? this.getInjectionDiv() : undefined,
);
getFocusManager().registerTree(this);
// Only the top-level and flyout workspaces should be tabbable.
getFocusManager().registerTree(this, !!this.injectionDiv || this.isFlyout);
return this.svgGroup_;
}
@@ -2807,13 +2806,12 @@ export class WorkspaceSvg
/** See IFocusableTree.onTreeBlur. */
onTreeBlur(nextTree: IFocusableTree | null): void {
// If the flyout loses focus, make sure to close it unless focus is being
// lost to a different element on the page.
if (nextTree && this.isFlyout && this.targetWorkspace) {
// lost to the toolbox.
if (this.isFlyout && this.targetWorkspace) {
// Only hide the flyout if the flyout's workspace is losing focus and that
// focus isn't returning to the flyout itself or the toolbox.
const flyout = this.targetWorkspace.getFlyout();
const toolbox = this.targetWorkspace.getToolbox();
if (flyout && nextTree === flyout) return;
if (toolbox && nextTree === toolbox) return;
if (toolbox) toolbox.clearSelection();
if (flyout && isAutoHideable(flyout)) flyout.autoHide(false);

File diff suppressed because it is too large Load Diff

View File

@@ -39,97 +39,76 @@
<div id="mocha"></div>
<div id="failureCount" style="display: none" tests_failed="unset"></div>
<div id="failureMessages" style="display: none"></div>
<div id="testFocusableTree1" tabindex="-1">
<div id="testFocusableTree1">
Focusable tree 1
<div id="testFocusableTree1.node1" style="margin-left: 1em" tabindex="-1">
<div id="testFocusableTree1.node1" style="margin-left: 1em">
Tree 1 node 1
<div
id="testFocusableTree1.node1.child1"
style="margin-left: 2em"
tabindex="-1">
<div id="testFocusableTree1.node1.child1" style="margin-left: 2em">
Tree 1 node 1 child 1
<div
id="testFocusableTree1.node1.child1.unregisteredChild1"
style="margin-left: 3em"
tabindex="-1">
style="margin-left: 3em">
Tree 1 node 1 child 1 child 1 (unregistered)
</div>
</div>
</div>
<div id="testFocusableTree1.node2" style="margin-left: 1em" tabindex="-1">
<div id="testFocusableTree1.node2" style="margin-left: 1em">
Tree 1 node 2
<div
id="testFocusableTree1.node2.unregisteredChild1"
style="margin-left: 2em"
tabindex="-1">
style="margin-left: 2em">
Tree 1 node 2 child 2 (unregistered)
</div>
</div>
<div
id="testFocusableTree1.unregisteredChild1"
style="margin-left: 1em"
tabindex="-1">
<div id="testFocusableTree1.unregisteredChild1" style="margin-left: 1em">
Tree 1 child 1 (unregistered)
</div>
</div>
<div id="testFocusableTree2" tabindex="-1">
<div id="testFocusableTree2">
Focusable tree 2
<div id="testFocusableTree2.node1" style="margin-left: 1em" tabindex="-1">
<div id="testFocusableTree2.node1" style="margin-left: 1em">
Tree 2 node 1
<div
id="testFocusableNestedTree4"
style="margin-left: 2em"
tabindex="-1">
<div id="testFocusableNestedTree4" style="margin-left: 2em">
Nested tree 4
<div
id="testFocusableNestedTree4.node1"
style="margin-left: 3em"
tabindex="-1">
<div id="testFocusableNestedTree4.node1" style="margin-left: 3em">
Tree 4 node 1 (nested)
<div
id="testFocusableNestedTree4.node1.unregisteredChild1"
style="margin-left: 4em"
tabindex="-1">
style="margin-left: 4em">
Tree 4 node 1 child 1 (unregistered)
</div>
</div>
</div>
</div>
<div id="testFocusableNestedTree5" style="margin-left: 1em" tabindex="-1">
<div id="testFocusableNestedTree5" style="margin-left: 1em">
Nested tree 5
<div
id="testFocusableNestedTree5.node1"
style="margin-left: 2em"
tabindex="-1">
<div id="testFocusableNestedTree5.node1" style="margin-left: 2em">
Tree 5 node 1 (nested)
</div>
</div>
</div>
<div id="testUnregisteredFocusableTree3" tabindex="-1">
<div id="testUnregisteredFocusableTree3">
Unregistered tree 3
<div
id="testUnregisteredFocusableTree3.node1"
style="margin-left: 1em"
tabindex="-1">
<div id="testUnregisteredFocusableTree3.node1" style="margin-left: 1em">
Tree 3 node 1 (unregistered)
</div>
</div>
<div id="testUnfocusableElement">Unfocusable element</div>
<div id="nonTreeElementForEphemeralFocus" tabindex="-1" />
<div id="nonTreeElementForEphemeralFocus" />
<svg width="250" height="250">
<g id="testFocusableGroup1" tabindex="-1">
<g id="testFocusableGroup1.node1" tabindex="-1">
<g id="testFocusableGroup1">
<g id="testFocusableGroup1.node1">
<rect x="0" y="0" width="250" height="30" fill="grey" />
<text x="10" y="20" class="svgText">Group 1 node 1</text>
<g id="testFocusableGroup1.node1.child1" tabindex="-1">
<g id="testFocusableGroup1.node1.child1">
<rect x="0" y="30" width="250" height="30" fill="lightgrey" />
<text x="10" y="50" class="svgText">Tree 1 node 1 child 1</text>
</g>
</g>
<g id="testFocusableGroup1.node2" tabindex="-1">
<g id="testFocusableGroup1.node2">
<rect x="0" y="60" width="250" height="30" fill="grey" />
<text x="10" y="80" class="svgText">Group 1 node 2</text>
<g id="testFocusableGroup1.node2.unregisteredChild1" tabindex="-1">
<g id="testFocusableGroup1.node2.unregisteredChild1">
<rect x="0" y="90" width="250" height="30" fill="lightgrey" />
<text x="10" y="110" class="svgText">
Tree 1 node 2 child 2 (unregistered)
@@ -137,27 +116,27 @@
</g>
</g>
</g>
<g id="testFocusableGroup2" tabindex="-1">
<g id="testFocusableGroup2.node1" tabindex="-1">
<g id="testFocusableGroup2">
<g id="testFocusableGroup2.node1">
<rect x="0" y="120" width="250" height="30" fill="grey" />
<text x="10" y="140" class="svgText">Group 2 node 1</text>
</g>
<g id="testFocusableNestedGroup4" tabindex="-1">
<g id="testFocusableNestedGroup4.node1" tabindex="-1">
<g id="testFocusableNestedGroup4">
<g id="testFocusableNestedGroup4.node1">
<rect x="0" y="150" width="250" height="30" fill="lightgrey" />
<text x="10" y="170" class="svgText">Group 4 node 1 (nested)</text>
</g>
</g>
</g>
<g id="testUnregisteredFocusableGroup3" tabindex="-1">
<g id="testUnregisteredFocusableGroup3.node1" tabindex="-1">
<g id="testUnregisteredFocusableGroup3">
<g id="testUnregisteredFocusableGroup3.node1">
<rect x="0" y="180" width="250" height="30" fill="grey" />
<text x="10" y="200" class="svgText">
Tree 3 node 1 (unregistered)
</text>
</g>
</g>
<g id="nonTreeGroupForEphemeralFocus" tabindex="-1"></g>
<g id="nonTreeGroupForEphemeralFocus"></g>
</svg>
<!-- Load mocha et al. before Blockly and the test modules so that
we can safely import the test modules that make calls