feat: Add keyboard navigation support for icons. (#9072)

* feat: Add keyboard navigation support for icons.

* chore: Satisfy the linter.
This commit is contained in:
Aaron Dodson
2025-05-20 08:52:18 -07:00
committed by GitHub
parent 135da402ef
commit 53d7876539
6 changed files with 175 additions and 1 deletions

View File

@@ -178,4 +178,13 @@ export abstract class Icon implements IIcon {
canBeFocused(): boolean {
return true;
}
/**
* Returns the block that this icon is attached to.
*
* @returns The block this icon is attached to.
*/
getSourceBlock(): Block {
return this.sourceBlock;
}
}

View File

@@ -21,6 +21,9 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
* @returns The first field or input of the given block, if any.
*/
getFirstChild(current: BlockSvg): IFocusableNode | null {
const icons = current.getIcons();
if (icons.length) return icons[0];
for (const input of current.inputList) {
for (const field of input.fieldRow) {
return field;

View File

@@ -85,7 +85,8 @@ export class FieldNavigationPolicy implements INavigationPolicy<Field<any>> {
fieldIdx = block.inputList[i - 1].fieldRow.length - 1;
}
}
return null;
return block.getIcons().pop() ?? null;
}
/**

View File

@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {BlockSvg} from '../block_svg.js';
import {Icon} from '../icons/icon.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from an icon.
*/
export class IconNavigationPolicy implements INavigationPolicy<Icon> {
/**
* Returns the first child of the given icon.
*
* @param _current The icon to return the first child of.
* @returns Null.
*/
getFirstChild(_current: Icon): IFocusableNode | null {
return null;
}
/**
* Returns the parent of the given icon.
*
* @param current The icon to return the parent of.
* @returns The source block of the given icon.
*/
getParent(current: Icon): IFocusableNode | null {
return current.getSourceBlock() as BlockSvg;
}
/**
* Returns the next peer node of the given icon.
*
* @param current The icon to find the following element of.
* @returns The next icon, field or input following this icon, if any.
*/
getNextSibling(current: Icon): IFocusableNode | null {
const block = current.getSourceBlock() as BlockSvg;
const icons = block.getIcons();
const currentIndex = icons.indexOf(current);
if (currentIndex >= 0 && currentIndex + 1 < icons.length) {
return icons[currentIndex + 1];
}
for (const input of block.inputList) {
if (input.fieldRow.length) return input.fieldRow[0];
if (input.connection?.targetBlock())
return input.connection.targetBlock() as BlockSvg;
}
return null;
}
/**
* Returns the previous peer node of the given icon.
*
* @param current The icon to find the preceding element of.
* @returns The icon's previous icon, if any.
*/
getPreviousSibling(current: Icon): IFocusableNode | null {
const block = current.getSourceBlock() as BlockSvg;
const icons = block.getIcons();
const currentIndex = icons.indexOf(current);
if (currentIndex >= 1) {
return icons[currentIndex - 1];
}
return null;
}
/**
* Returns whether or not the given icon can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given icon can be focused.
*/
isNavigable(current: Icon): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is an Icon.
*/
isApplicable(current: any): current is Icon {
return current instanceof Icon;
}
}

View File

@@ -9,6 +9,7 @@ import type {INavigationPolicy} from './interfaces/i_navigation_policy.js';
import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js';
import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js';
import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js';
import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js';
import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js';
type RuleList<T> = INavigationPolicy<T>[];
@@ -27,6 +28,7 @@ export class Navigator {
new FieldNavigationPolicy(),
new ConnectionNavigationPolicy(),
new WorkspaceNavigationPolicy(),
new IconNavigationPolicy(),
];
/**

View File

@@ -404,6 +404,29 @@ suite('Navigation', function () {
this.blocks.hiddenInput.inputList[2].fieldRow[0],
);
});
test('from icon to icon', function () {
this.blocks.statementInput1.setCommentText('test');
this.blocks.statementInput1.setWarningText('test');
const icons = this.blocks.statementInput1.getIcons();
const nextNode = this.navigator.getNextSibling(icons[0]);
assert.equal(nextNode, icons[1]);
});
test('from icon to field', function () {
this.blocks.statementInput1.setCommentText('test');
this.blocks.statementInput1.setWarningText('test');
const icons = this.blocks.statementInput1.getIcons();
const nextNode = this.navigator.getNextSibling(icons[1]);
assert.equal(
nextNode,
this.blocks.statementInput1.inputList[0].fieldRow[0],
);
});
test('from icon to null', function () {
this.blocks.dummyInput.setCommentText('test');
const icons = this.blocks.dummyInput.getIcons();
const nextNode = this.navigator.getNextSibling(icons[0]);
assert.isNull(nextNode);
});
});
suite('Previous', function () {
@@ -496,6 +519,28 @@ suite('Navigation', function () {
this.blocks.hiddenInput.inputList[0].fieldRow[0],
);
});
test('from icon to icon', function () {
this.blocks.statementInput1.setCommentText('test');
this.blocks.statementInput1.setWarningText('test');
const icons = this.blocks.statementInput1.getIcons();
const prevNode = this.navigator.getPreviousSibling(icons[1]);
assert.equal(prevNode, icons[0]);
});
test('from field to icon', function () {
this.blocks.statementInput1.setCommentText('test');
this.blocks.statementInput1.setWarningText('test');
const icons = this.blocks.statementInput1.getIcons();
const prevNode = this.navigator.getPreviousSibling(
this.blocks.statementInput1.inputList[0].fieldRow[0],
);
assert.equal(prevNode, icons[1]);
});
test('from icon to null', function () {
this.blocks.dummyInput.setCommentText('test');
const icons = this.blocks.dummyInput.getIcons();
const prevNode = this.navigator.getPreviousSibling(icons[0]);
assert.isNull(prevNode);
});
});
suite('In', function () {
@@ -564,6 +609,18 @@ suite('Navigation', function () {
const inNode = this.navigator.getFirstChild(this.emptyWorkspace);
assert.isNull(inNode);
});
test('from block to icon', function () {
this.blocks.dummyInput.setCommentText('test');
const icons = this.blocks.dummyInput.getIcons();
const inNode = this.navigator.getFirstChild(this.blocks.dummyInput);
assert.equal(inNode, icons[0]);
});
test('from icon to null', function () {
this.blocks.dummyInput.setCommentText('test');
const icons = this.blocks.dummyInput.getIcons();
const inNode = this.navigator.getFirstChild(icons[0]);
assert.isNull(inNode);
});
});
suite('Out', function () {
@@ -661,6 +718,12 @@ suite('Navigation', function () {
const outNode = this.navigator.getParent(this.blocks.outputNextBlock);
assert.equal(outNode, inputConnection);
});
test('from icon to block', function () {
this.blocks.dummyInput.setCommentText('test');
const icons = this.blocks.dummyInput.getIcons();
const outNode = this.navigator.getParent(icons[0]);
assert.equal(outNode, this.blocks.dummyInput);
});
});
});
});