Skip to content
Closed
69 changes: 68 additions & 1 deletion core/flyout_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -317,6 +321,9 @@ export abstract class Flyout
this.workspace_
.getThemeManager()
.subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity');

getFocusManager().registerTree(this);

return this.svgGroup_;
}

Expand Down Expand Up @@ -398,6 +405,7 @@ export abstract class Flyout
if (this.svgGroup_) {
dom.removeNode(this.svgGroup_);
}
getFocusManager().unregisterTree(this);
}

/**
Expand Down Expand Up @@ -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<IFocusableTree> {
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);
}
}
31 changes: 29 additions & 2 deletions core/flyout_button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
);

Expand Down Expand Up @@ -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. */
Expand Down
3 changes: 2 additions & 1 deletion core/interfaces/i_flyout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion core/interfaces/i_toolbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 3 additions & 1 deletion core/interfaces/i_toolbox_item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions core/toolbox/category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions core/toolbox/separator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
77 changes: 75 additions & 2 deletions core/toolbox/toolbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -163,6 +171,7 @@ export class Toolbox
ComponentManager.Capability.DRAG_TARGET,
],
});
getFocusManager().registerTree(this);
}

/**
Expand All @@ -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_);

Expand All @@ -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');
Expand Down Expand Up @@ -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<IFocusableTree> {
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. */
Expand Down
Loading