Skip to content

Commit

Permalink
feat: support keyboard navigation of flyout buttons (google#7852)
Browse files Browse the repository at this point in the history
* feat: support keyboard navigation of flyout buttons

* fix: use FlyoutItem type for flyout contents, rework navigateBetweenStacks for flyouts
  • Loading branch information
mikeharv authored and johnnesky committed Apr 13, 2024
1 parent ac17298 commit 14e9867
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 3 deletions.
35 changes: 34 additions & 1 deletion core/flyout_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -161,6 +161,11 @@ export abstract class Flyout
*/
protected buttons_: FlyoutButton[] = [];

/**
* List of visible buttons and blocks.
*/
protected contents: FlyoutItem[] = [];

/**
* List of event listeners.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 43 additions & 1 deletion core/flyout_button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
*
Expand Down
75 changes: 74 additions & 1 deletion core/keyboard_nav/ast_node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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++) {
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -740,6 +812,7 @@ export namespace ASTNode {
PREVIOUS = 'previous',
STACK = 'stack',
WORKSPACE = 'workspace',
BUTTON = 'button',
}
}

Expand Down
35 changes: 35 additions & 0 deletions core/renderers/common/marker_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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();
Expand Down

0 comments on commit 14e9867

Please sign in to comment.