mirror of
https://github.com/google/blockly.git
synced 2026-01-08 01:20:12 +01:00
feat: support keyboard navigation of flyout buttons (#7852)
* feat: support keyboard navigation of flyout buttons * fix: use FlyoutItem type for flyout contents, rework navigateBetweenStacks for flyouts
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
|
||||
import type {Abstract as AbstractEvent} from './events/events_abstract.js';
|
||||
import type {Block} from './block.js';
|
||||
import type {BlockSvg} from './block_svg.js';
|
||||
import {BlockSvg} from './block_svg.js';
|
||||
import * as browserEvents from './browser_events.js';
|
||||
import * as common from './common.js';
|
||||
import {ComponentManager} from './component_manager.js';
|
||||
@@ -161,6 +161,11 @@ export abstract class Flyout
|
||||
*/
|
||||
protected buttons_: FlyoutButton[] = [];
|
||||
|
||||
/**
|
||||
* List of visible buttons and blocks.
|
||||
*/
|
||||
protected contents: FlyoutItem[] = [];
|
||||
|
||||
/**
|
||||
* List of event listeners.
|
||||
*/
|
||||
@@ -546,6 +551,32 @@ export abstract class Flyout
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of buttons and blocks of the current flyout.
|
||||
*
|
||||
* @returns The array of flyout buttons and blocks.
|
||||
*/
|
||||
getContents(): FlyoutItem[] {
|
||||
return this.contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the list of buttons and blocks on the flyout.
|
||||
*
|
||||
* @param contents - The array of items for the flyout.
|
||||
*/
|
||||
setContents(contents: FlyoutItem[]): void {
|
||||
const blocksAndButtons = contents.map((item) => {
|
||||
if (item.type === 'block' && item.block) {
|
||||
return item.block as BlockSvg;
|
||||
}
|
||||
if (item.type === 'button' && item.button) {
|
||||
return item.button as FlyoutButton;
|
||||
}
|
||||
});
|
||||
|
||||
this.contents = blocksAndButtons as FlyoutItem[];
|
||||
}
|
||||
/**
|
||||
* Update the display property of the flyout based whether it thinks it should
|
||||
* be visible and whether its containing workspace is visible.
|
||||
@@ -651,6 +682,8 @@ export abstract class Flyout
|
||||
|
||||
renderManagement.triggerQueuedRenders(this.workspace_);
|
||||
|
||||
this.setContents(flyoutInfo.contents);
|
||||
|
||||
this.layout_(flyoutInfo.contents, flyoutInfo.gaps);
|
||||
|
||||
if (this.horizontalLayout) {
|
||||
|
||||
@@ -20,11 +20,12 @@ import * as style from './utils/style.js';
|
||||
import {Svg} from './utils/svg.js';
|
||||
import type * as toolbox from './utils/toolbox.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
import type {IASTNodeLocationSvg} from './blockly.js';
|
||||
|
||||
/**
|
||||
* Class for a button or label in the flyout.
|
||||
*/
|
||||
export class FlyoutButton {
|
||||
export class FlyoutButton implements IASTNodeLocationSvg {
|
||||
/** The horizontal margin around the text in the button. */
|
||||
static TEXT_MARGIN_X = 5;
|
||||
|
||||
@@ -55,6 +56,12 @@ export class FlyoutButton {
|
||||
/** The SVG element with the text of the label or button. */
|
||||
private svgText: SVGTextElement | null = null;
|
||||
|
||||
/**
|
||||
* Holds the cursors svg element when the cursor is attached to the button.
|
||||
* This is null if there is no cursor on the button.
|
||||
*/
|
||||
cursorSvg: SVGElement | null = null;
|
||||
|
||||
/**
|
||||
* @param workspace The workspace in which to place this button.
|
||||
* @param targetWorkspace The flyout's target workspace.
|
||||
@@ -255,6 +262,15 @@ export class FlyoutButton {
|
||||
return this.targetWorkspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the button's workspace.
|
||||
*
|
||||
* @returns The workspace in which to place this button.
|
||||
*/
|
||||
getWorkspace(): WorkspaceSvg {
|
||||
return this.workspace;
|
||||
}
|
||||
|
||||
/** Dispose of this button. */
|
||||
dispose() {
|
||||
if (this.onMouseUpWrapper) {
|
||||
@@ -268,6 +284,32 @@ export class FlyoutButton {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the cursor SVG to this buttons's SVG group.
|
||||
*
|
||||
* @param cursorSvg The SVG root of the cursor to be added to the button SVG
|
||||
* group.
|
||||
*/
|
||||
setCursorSvg(cursorSvg: SVGElement) {
|
||||
if (!cursorSvg) {
|
||||
this.cursorSvg = null;
|
||||
return;
|
||||
}
|
||||
if (this.svgGroup) {
|
||||
this.svgGroup.appendChild(cursorSvg);
|
||||
this.cursorSvg = cursorSvg;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Required by IASTNodeLocationSvg, but not used. A marker cannot be set on a
|
||||
* button. If the 'mark' shortcut is used on a button, its associated callback
|
||||
* function is triggered.
|
||||
*/
|
||||
setMarkerSvg() {
|
||||
throw new Error('Attempted to set a marker on a button.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Do something when the button is clicked.
|
||||
*
|
||||
|
||||
@@ -21,6 +21,9 @@ import type {IASTNodeLocation} from '../interfaces/i_ast_node_location.js';
|
||||
import type {IASTNodeLocationWithBlock} from '../interfaces/i_ast_node_location_with_block.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import type {Workspace} from '../workspace.js';
|
||||
import {FlyoutButton} from '../flyout_button.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {Flyout} from '../flyout_base.js';
|
||||
|
||||
/**
|
||||
* Class for an AST node.
|
||||
@@ -286,6 +289,9 @@ export class ASTNode {
|
||||
if (!curLocationAsBlock || curLocationAsBlock.isDeadOrDying()) {
|
||||
return null;
|
||||
}
|
||||
if (curLocationAsBlock.workspace.isFlyout) {
|
||||
return this.navigateFlyoutContents(forward);
|
||||
}
|
||||
const curRoot = curLocationAsBlock.getRootBlock();
|
||||
const topBlocks = curRoot.workspace.getTopBlocks(true);
|
||||
for (let i = 0; i < topBlocks.length; i++) {
|
||||
@@ -304,6 +310,50 @@ export class ASTNode {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate between buttons and stacks of blocks on the flyout workspace.
|
||||
*
|
||||
* @param forward True to go forward. False to go backwards.
|
||||
* @returns The next button, or next stack's first block, or null
|
||||
*/
|
||||
private navigateFlyoutContents(forward: boolean): ASTNode | null {
|
||||
const nodeType = this.getType();
|
||||
let location;
|
||||
let targetWorkspace;
|
||||
|
||||
switch (nodeType) {
|
||||
case ASTNode.types.STACK: {
|
||||
location = this.getLocation() as Block;
|
||||
const workspace = location.workspace as WorkspaceSvg;
|
||||
targetWorkspace = workspace.targetWorkspace as WorkspaceSvg;
|
||||
break;
|
||||
}
|
||||
case ASTNode.types.BUTTON: {
|
||||
location = this.getLocation() as FlyoutButton;
|
||||
targetWorkspace = location.getTargetWorkspace() as WorkspaceSvg;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const flyout = targetWorkspace.getFlyout() as Flyout;
|
||||
const flyoutContents = flyout.getContents() as (Block | FlyoutButton)[];
|
||||
|
||||
const currentIndex = flyoutContents.indexOf(location);
|
||||
const resultIndex = forward ? currentIndex + 1 : currentIndex - 1;
|
||||
if (resultIndex === -1 || resultIndex === flyoutContents.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newLocation = flyoutContents[resultIndex];
|
||||
if (newLocation instanceof FlyoutButton) {
|
||||
return ASTNode.createButtonNode(newLocation);
|
||||
} else {
|
||||
return ASTNode.createStackNode(newLocation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the top most AST node for a given block.
|
||||
* This is either the previous connection, output connection or block
|
||||
@@ -385,7 +435,7 @@ export class ASTNode {
|
||||
* Finds the source block of the location of this node.
|
||||
*
|
||||
* @returns The source block of the location, or null if the node is of type
|
||||
* workspace.
|
||||
* workspace or button.
|
||||
*/
|
||||
getSourceBlock(): Block | null {
|
||||
if (this.getType() === ASTNode.types.BLOCK) {
|
||||
@@ -394,6 +444,8 @@ export class ASTNode {
|
||||
return this.getLocation() as Block;
|
||||
} else if (this.getType() === ASTNode.types.WORKSPACE) {
|
||||
return null;
|
||||
} else if (this.getType() === ASTNode.types.BUTTON) {
|
||||
return null;
|
||||
} else {
|
||||
return (this.getLocation() as IASTNodeLocationWithBlock).getSourceBlock();
|
||||
}
|
||||
@@ -435,6 +487,8 @@ export class ASTNode {
|
||||
const targetConnection = connection.targetConnection;
|
||||
return ASTNode.createConnectionNode(targetConnection!);
|
||||
}
|
||||
case ASTNode.types.BUTTON:
|
||||
return this.navigateFlyoutContents(true);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -513,6 +567,8 @@ export class ASTNode {
|
||||
const connection = this.location as Connection;
|
||||
return ASTNode.createBlockNode(connection.getSourceBlock());
|
||||
}
|
||||
case ASTNode.types.BUTTON:
|
||||
return this.navigateFlyoutContents(false);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -688,6 +744,22 @@ export class ASTNode {
|
||||
return new ASTNode(ASTNode.types.STACK, topBlock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AST node of type button. A button in this case refers
|
||||
* specifically to a button in a flyout.
|
||||
*
|
||||
* @param button A top block has no parent and can be found in the list
|
||||
* returned by workspace.getTopBlocks().
|
||||
* @returns An AST node of type stack that points to the top block on the
|
||||
* stack.
|
||||
*/
|
||||
static createButtonNode(button: FlyoutButton): ASTNode | null {
|
||||
if (!button) {
|
||||
return null;
|
||||
}
|
||||
return new ASTNode(ASTNode.types.BUTTON, button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AST node pointing to a workspace.
|
||||
*
|
||||
@@ -740,6 +812,7 @@ export namespace ASTNode {
|
||||
PREVIOUS = 'previous',
|
||||
STACK = 'stack',
|
||||
WORKSPACE = 'workspace',
|
||||
BUTTON = 'button',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import * as svgPaths from '../../utils/svg_paths.js';
|
||||
import type {WorkspaceSvg} from '../../workspace_svg.js';
|
||||
|
||||
import type {ConstantProvider, Notch, PuzzleTab} from './constants.js';
|
||||
import {FlyoutButton} from '../../flyout_button.js';
|
||||
|
||||
/** The name of the CSS class for a cursor. */
|
||||
const CURSOR_CLASS = 'blocklyCursor';
|
||||
@@ -205,6 +206,8 @@ export class MarkerSvg {
|
||||
this.showWithCoordinates_(curNode);
|
||||
} else if (curNode.getType() === ASTNode.types.STACK) {
|
||||
this.showWithStack_(curNode);
|
||||
} else if (curNode.getType() === ASTNode.types.BUTTON) {
|
||||
this.showWithButton_(curNode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +381,38 @@ export class MarkerSvg {
|
||||
this.showCurrent_();
|
||||
}
|
||||
|
||||
/**
|
||||
* Position and display the marker for a flyout button.
|
||||
* This is a box with extra padding around the button.
|
||||
*
|
||||
* @param curNode The node to draw the marker for.
|
||||
*/
|
||||
protected showWithButton_(curNode: ASTNode) {
|
||||
const button = curNode.getLocation() as FlyoutButton;
|
||||
|
||||
// Gets the height and width of entire stack.
|
||||
const heightWidth = {height: button.height, width: button.width};
|
||||
|
||||
// Add padding so that being on a button looks similar to being on a stack.
|
||||
const width = heightWidth.width + this.constants_.CURSOR_STACK_PADDING;
|
||||
const height = heightWidth.height + this.constants_.CURSOR_STACK_PADDING;
|
||||
|
||||
// Shift the rectangle slightly to upper left so padding is equal on all
|
||||
// sides.
|
||||
const xPadding = -this.constants_.CURSOR_STACK_PADDING / 2;
|
||||
const yPadding = -this.constants_.CURSOR_STACK_PADDING / 2;
|
||||
|
||||
let x = xPadding;
|
||||
const y = yPadding;
|
||||
|
||||
if (this.workspace.RTL) {
|
||||
x = -(width + xPadding);
|
||||
}
|
||||
this.positionRect_(x, y, width, height);
|
||||
this.setParent_(button);
|
||||
this.showCurrent_();
|
||||
}
|
||||
|
||||
/** Show the current marker. */
|
||||
protected showCurrent_() {
|
||||
this.hide();
|
||||
|
||||
Reference in New Issue
Block a user