diff --git a/core/flyout_base.ts b/core/flyout_base.ts index e738470a606..a0ba75eb9fe 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -21,9 +21,12 @@ import * as eventUtils from './events/utils.js'; import {FlyoutItem} from './flyout_item.js'; import {FlyoutMetricsManager} from './flyout_metrics_manager.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'; +import {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {Options} from './options.js'; import * as registry from './registry.js'; import * as renderManagement from './render_management.js'; @@ -43,7 +46,7 @@ import {WorkspaceSvg} from './workspace_svg.js'; */ export abstract class Flyout extends DeleteArea - implements IAutoHideable, IFlyout + implements IAutoHideable, IFlyout, IFocusableNode { /** * Position the flyout. @@ -303,6 +306,7 @@ 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( @@ -317,6 +321,9 @@ export abstract class Flyout this.workspace_ .getThemeManager() .subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity'); + + getFocusManager().registerTree(this); + return this.svgGroup_; } @@ -398,6 +405,7 @@ export abstract class Flyout if (this.svgGroup_) { dom.removeNode(this.svgGroup_); } + getFocusManager().unregisterTree(this); } /** @@ -961,4 +969,63 @@ export abstract class Flyout return null; } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + if (!this.svgGroup_) throw new Error('Flyout DOM is not yet created.'); + return this.svgGroup_; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableTree.getRootFocusableNode. */ + getRootFocusableNode(): IFocusableNode { + return this; + } + + /** See IFocusableTree.getRestoredFocusableNode. */ + getRestoredFocusableNode( + _previousNode: IFocusableNode | null, + ): IFocusableNode | null { + return null; + } + + /** See IFocusableTree.getNestedTrees. */ + getNestedTrees(): Array { + return [this.workspace_]; + } + + /** See IFocusableTree.lookUpFocusableNode. */ + 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; + } + + /** See IFocusableTree.onTreeFocus. */ + onTreeFocus( + _node: IFocusableNode, + _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); + } } diff --git a/core/flyout_button.ts b/core/flyout_button.ts index 3b9b2fe0735..666a2e081bf 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -15,7 +15,10 @@ import type {IASTNodeLocationSvg} from './blockly.js'; import * as browserEvents from './browser_events.js'; import * as Css from './css.js'; import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {IRenderedElement} from './interfaces/i_rendered_element.js'; +import {idGenerator} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -29,7 +32,11 @@ import type {WorkspaceSvg} from './workspace_svg.js'; * Class for a button or label in the flyout. */ export class FlyoutButton - implements IASTNodeLocationSvg, IBoundedElement, IRenderedElement + implements + IASTNodeLocationSvg, + IBoundedElement, + IRenderedElement, + IFocusableNode { /** The horizontal margin around the text in the button. */ static TEXT_MARGIN_X = 5; @@ -68,6 +75,9 @@ export class FlyoutButton */ cursorSvg: SVGElement | null = null; + /** The unique ID for this FlyoutButton. */ + private id: string; + /** * @param workspace The workspace in which to place this button. * @param targetWorkspace The flyout's target workspace. @@ -105,9 +115,10 @@ export class FlyoutButton cssClass += ' ' + this.cssClass; } + this.id = idGenerator.getNextUniqueId(); this.svgGroup = dom.createSvgElement( Svg.G, - {'class': cssClass}, + {'id': this.id, 'class': cssClass, 'tabindex': '-1'}, this.workspace.getCanvas(), ); @@ -389,6 +400,22 @@ export class FlyoutButton getSvgRoot() { return this.svgGroup; } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.svgGroup; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.workspace; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} } /** CSS for buttons and labels. See css.js for use. */ diff --git a/core/interfaces/i_flyout.ts b/core/interfaces/i_flyout.ts index 42204775ece..067cd5ef20d 100644 --- a/core/interfaces/i_flyout.ts +++ b/core/interfaces/i_flyout.ts @@ -12,12 +12,13 @@ import type {Coordinate} from '../utils/coordinate.js'; import type {Svg} from '../utils/svg.js'; import type {FlyoutDefinition} from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; +import {IFocusableTree} from './i_focusable_tree.js'; import type {IRegistrable} from './i_registrable.js'; /** * Interface for a flyout. */ -export interface IFlyout extends IRegistrable { +export interface IFlyout extends IRegistrable, IFocusableTree { /** Whether the flyout is laid out horizontally or not. */ horizontalLayout: boolean; diff --git a/core/interfaces/i_toolbox.ts b/core/interfaces/i_toolbox.ts index 1780af94d8a..f5d9c9fd7c6 100644 --- a/core/interfaces/i_toolbox.ts +++ b/core/interfaces/i_toolbox.ts @@ -9,13 +9,14 @@ import type {ToolboxInfo} from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; import type {IFlyout} from './i_flyout.js'; +import type {IFocusableTree} from './i_focusable_tree.js'; import type {IRegistrable} from './i_registrable.js'; import type {IToolboxItem} from './i_toolbox_item.js'; /** * Interface for a toolbox. */ -export interface IToolbox extends IRegistrable { +export interface IToolbox extends IRegistrable, IFocusableTree { /** Initializes the toolbox. */ init(): void; diff --git a/core/interfaces/i_toolbox_item.ts b/core/interfaces/i_toolbox_item.ts index e3c9864f0c0..661624fd7e8 100644 --- a/core/interfaces/i_toolbox_item.ts +++ b/core/interfaces/i_toolbox_item.ts @@ -6,10 +6,12 @@ // Former goog.module ID: Blockly.IToolboxItem +import type {IFocusableNode} from './i_focusable_node.js'; + /** * Interface for an item in the toolbox. */ -export interface IToolboxItem { +export interface IToolboxItem extends IFocusableNode { /** * Initializes the toolbox item. * This includes creating the DOM and updating the state of any items based diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index d8ee8736ea6..fc7d1aa03cf 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -225,6 +225,8 @@ export class ToolboxCategory */ protected createContainer_(): HTMLDivElement { const container = document.createElement('div'); + container.tabIndex = -1; + container.id = this.getId(); const className = this.cssConfig_['container']; if (className) { dom.addClass(container, className); diff --git a/core/toolbox/separator.ts b/core/toolbox/separator.ts index 31ccb7e42f3..44ae358cf53 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -54,6 +54,8 @@ export class ToolboxSeparator extends ToolboxItem { */ protected createDom_(): HTMLDivElement { const container = document.createElement('div'); + container.tabIndex = -1; + container.id = this.getId(); const className = this.cssConfig_['container']; if (className) { dom.addClass(container, className); diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index b0fd82e97f2..ceb756afbd6 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -22,11 +22,14 @@ import {DeleteArea} from '../delete_area.js'; 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 type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js'; import {isDeletable} from '../interfaces/i_deletable.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IFlyout} from '../interfaces/i_flyout.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import type {IKeyboardAccessible} from '../interfaces/i_keyboard_accessible.js'; import type {ISelectableToolboxItem} from '../interfaces/i_selectable_toolbox_item.js'; import {isSelectableToolboxItem} from '../interfaces/i_selectable_toolbox_item.js'; @@ -51,7 +54,12 @@ import {CollapsibleToolboxCategory} from './collapsible_category.js'; */ export class Toolbox extends DeleteArea - implements IAutoHideable, IKeyboardAccessible, IStyleable, IToolbox + implements + IAutoHideable, + IKeyboardAccessible, + IStyleable, + IToolbox, + IFocusableNode { /** * The unique ID for this component that is used to register with the @@ -163,6 +171,7 @@ export class Toolbox ComponentManager.Capability.DRAG_TARGET, ], }); + getFocusManager().registerTree(this); } /** @@ -177,7 +186,6 @@ export class Toolbox const container = this.createContainer_(); this.contentsDiv_ = this.createContentsContainer_(); - this.contentsDiv_.tabIndex = 0; aria.setRole(this.contentsDiv_, aria.Role.TREE); container.appendChild(this.contentsDiv_); @@ -194,6 +202,7 @@ 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'); @@ -1077,7 +1086,71 @@ export class Toolbox this.workspace_.getThemeManager().unsubscribe(this.HtmlDiv); dom.removeNode(this.HtmlDiv); } + + getFocusManager().unregisterTree(this); + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + if (!this.HtmlDiv) throw Error('Toolbox DOM has not yet been created.'); + return this.HtmlDiv; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableTree.getRootFocusableNode. */ + getRootFocusableNode(): IFocusableNode { + return this; + } + + /** See IFocusableTree.getRestoredFocusableNode. */ + getRestoredFocusableNode( + previousNode: IFocusableNode | null, + ): IFocusableNode | null { + // Always try to select the first selectable toolbox item rather than the + // root of the toolbox. + if (!previousNode || previousNode === this) { + return this.getToolboxItems().find((item) => item.isSelectable()) ?? null; + } + return null; } + + /** See IFocusableTree.getNestedTrees. */ + getNestedTrees(): Array { + return []; + } + + /** See IFocusableTree.lookUpFocusableNode. */ + lookUpFocusableNode(id: string): IFocusableNode | null { + return this.getToolboxItemById(id) as IFocusableNode; + } + + /** See IFocusableTree.onTreeFocus. */ + onTreeFocus( + node: IFocusableNode, + _previousTree: IFocusableTree | null, + ): void { + if (node !== this) { + // Only select the item if it isn't already selected so as to not toggle. + if (this.getSelectedItem() !== node) { + this.setSelectedItem(node as IToolboxItem); + } + } else { + this.clearSelection(); + } + } + + /** See IFocusableTree.onTreeBlur. */ + onTreeBlur(_nextTree: IFocusableTree | null): void {} } /** CSS for Toolbox. See css.js for use. */ diff --git a/core/toolbox/toolbox_item.ts b/core/toolbox/toolbox_item.ts index ef9d979ab43..0d46a5eadfd 100644 --- a/core/toolbox/toolbox_item.ts +++ b/core/toolbox/toolbox_item.ts @@ -12,6 +12,7 @@ // Former goog.module ID: Blockly.ToolboxItem import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import type {IToolboxItem} from '../interfaces/i_toolbox_item.js'; import * as idGenerator from '../utils/idgenerator.js'; @@ -148,5 +149,28 @@ export class ToolboxItem implements IToolboxItem { * @param _isVisible True if category should be visible. */ setVisible_(_isVisible: boolean) {} + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + const div = this.getDiv(); + if (!div) { + throw Error('Trying to access toolbox item before DOM is initialized.'); + } + if (!(div instanceof HTMLElement)) { + throw Error('Toolbox item div is unexpectedly not an HTML element.'); + } + return div as HTMLElement; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.parentToolbox_; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} } // nop by default diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 2648005b257..921fb72ecc7 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -45,7 +45,10 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {IDragTarget} from './interfaces/i_drag_target.js'; import type {IFlyout} from './interfaces/i_flyout.js'; -import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import { + isFocusableNode, + type IFocusableNode, +} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; import type {IToolbox} from './interfaces/i_toolbox.js'; @@ -2693,6 +2696,19 @@ export class WorkspaceSvg /** See IFocusableTree.lookUpFocusableNode. */ lookUpFocusableNode(id: string): IFocusableNode | null { + // Check against flyout items if this workspace is part of a flyout. Note + // that blocks may match against this pass before reaching getBlockById() + // below (but only for a flyout workspace). + const flyout = this.targetWorkspace?.getFlyout(); + if (this.isFlyout && flyout) { + for (const flyoutItem of flyout.getContents()) { + const elem = flyoutItem.getElement(); + if (isFocusableNode(elem) && elem.getFocusableElement().id === id) { + return elem; + } + } + } + return this.getBlockById(id) as IFocusableNode; } @@ -2703,7 +2719,19 @@ export class WorkspaceSvg ): void {} /** See IFocusableTree.onTreeBlur. */ - onTreeBlur(_nextTree: IFocusableTree | null): void {} + onTreeBlur(nextTree: IFocusableTree | null): void { + // If the flyout loses focus, make sure to close it. + 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 && flyout instanceof Flyout) flyout.autoHide(false); + } + } } /** diff --git a/tests/mocha/toolbox_test.js b/tests/mocha/toolbox_test.js index 10bfd335223..f32319c6779 100644 --- a/tests/mocha/toolbox_test.js +++ b/tests/mocha/toolbox_test.js @@ -54,6 +54,7 @@ suite('Toolbox', function () { const themeManagerSpy = sinon.spy(themeManager, 'subscribe'); const componentManager = this.toolbox.workspace_.getComponentManager(); sinon.stub(componentManager, 'addComponent'); + this.toolbox.dispose(); // Dispose of the old toolbox so that it can be reinited. this.toolbox.init(); sinon.assert.calledWith( themeManagerSpy, @@ -72,12 +73,14 @@ suite('Toolbox', function () { const renderSpy = sinon.spy(this.toolbox, 'render'); const componentManager = this.toolbox.workspace_.getComponentManager(); sinon.stub(componentManager, 'addComponent'); + this.toolbox.dispose(); // Dispose of the old toolbox so that it can be reinited. this.toolbox.init(); sinon.assert.calledOnce(renderSpy); }); test('Init called -> Flyout is initialized', function () { const componentManager = this.toolbox.workspace_.getComponentManager(); sinon.stub(componentManager, 'addComponent'); + this.toolbox.dispose(); // Dispose of the old toolbox so that it can be reinited. this.toolbox.init(); assert.isDefined(this.toolbox.getFlyout()); });