Skip to content

Commit 53d7876

Browse files
authored
feat: Add keyboard navigation support for icons. (#9072)
* feat: Add keyboard navigation support for icons. * chore: Satisfy the linter.
1 parent 135da40 commit 53d7876

6 files changed

Lines changed: 175 additions & 1 deletion

File tree

core/icons/icon.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,13 @@ export abstract class Icon implements IIcon {
178178
canBeFocused(): boolean {
179179
return true;
180180
}
181+
182+
/**
183+
* Returns the block that this icon is attached to.
184+
*
185+
* @returns The block this icon is attached to.
186+
*/
187+
getSourceBlock(): Block {
188+
return this.sourceBlock;
189+
}
181190
}

core/keyboard_nav/block_navigation_policy.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
2121
* @returns The first field or input of the given block, if any.
2222
*/
2323
getFirstChild(current: BlockSvg): IFocusableNode | null {
24+
const icons = current.getIcons();
25+
if (icons.length) return icons[0];
26+
2427
for (const input of current.inputList) {
2528
for (const field of input.fieldRow) {
2629
return field;

core/keyboard_nav/field_navigation_policy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ export class FieldNavigationPolicy implements INavigationPolicy<Field<any>> {
8585
fieldIdx = block.inputList[i - 1].fieldRow.length - 1;
8686
}
8787
}
88-
return null;
88+
89+
return block.getIcons().pop() ?? null;
8990
}
9091

9192
/**
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {BlockSvg} from '../block_svg.js';
8+
import {Icon} from '../icons/icon.js';
9+
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
10+
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
11+
12+
/**
13+
* Set of rules controlling keyboard navigation from an icon.
14+
*/
15+
export class IconNavigationPolicy implements INavigationPolicy<Icon> {
16+
/**
17+
* Returns the first child of the given icon.
18+
*
19+
* @param _current The icon to return the first child of.
20+
* @returns Null.
21+
*/
22+
getFirstChild(_current: Icon): IFocusableNode | null {
23+
return null;
24+
}
25+
26+
/**
27+
* Returns the parent of the given icon.
28+
*
29+
* @param current The icon to return the parent of.
30+
* @returns The source block of the given icon.
31+
*/
32+
getParent(current: Icon): IFocusableNode | null {
33+
return current.getSourceBlock() as BlockSvg;
34+
}
35+
36+
/**
37+
* Returns the next peer node of the given icon.
38+
*
39+
* @param current The icon to find the following element of.
40+
* @returns The next icon, field or input following this icon, if any.
41+
*/
42+
getNextSibling(current: Icon): IFocusableNode | null {
43+
const block = current.getSourceBlock() as BlockSvg;
44+
const icons = block.getIcons();
45+
const currentIndex = icons.indexOf(current);
46+
if (currentIndex >= 0 && currentIndex + 1 < icons.length) {
47+
return icons[currentIndex + 1];
48+
}
49+
50+
for (const input of block.inputList) {
51+
if (input.fieldRow.length) return input.fieldRow[0];
52+
53+
if (input.connection?.targetBlock())
54+
return input.connection.targetBlock() as BlockSvg;
55+
}
56+
57+
return null;
58+
}
59+
60+
/**
61+
* Returns the previous peer node of the given icon.
62+
*
63+
* @param current The icon to find the preceding element of.
64+
* @returns The icon's previous icon, if any.
65+
*/
66+
getPreviousSibling(current: Icon): IFocusableNode | null {
67+
const block = current.getSourceBlock() as BlockSvg;
68+
const icons = block.getIcons();
69+
const currentIndex = icons.indexOf(current);
70+
if (currentIndex >= 1) {
71+
return icons[currentIndex - 1];
72+
}
73+
74+
return null;
75+
}
76+
77+
/**
78+
* Returns whether or not the given icon can be navigated to.
79+
*
80+
* @param current The instance to check for navigability.
81+
* @returns True if the given icon can be focused.
82+
*/
83+
isNavigable(current: Icon): boolean {
84+
return current.canBeFocused();
85+
}
86+
87+
/**
88+
* Returns whether the given object can be navigated from by this policy.
89+
*
90+
* @param current The object to check if this policy applies to.
91+
* @returns True if the object is an Icon.
92+
*/
93+
isApplicable(current: any): current is Icon {
94+
return current instanceof Icon;
95+
}
96+
}

core/navigator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {INavigationPolicy} from './interfaces/i_navigation_policy.js';
99
import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js';
1010
import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js';
1111
import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js';
12+
import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js';
1213
import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js';
1314

1415
type RuleList<T> = INavigationPolicy<T>[];
@@ -27,6 +28,7 @@ export class Navigator {
2728
new FieldNavigationPolicy(),
2829
new ConnectionNavigationPolicy(),
2930
new WorkspaceNavigationPolicy(),
31+
new IconNavigationPolicy(),
3032
];
3133

3234
/**

tests/mocha/navigation_test.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,29 @@ suite('Navigation', function () {
404404
this.blocks.hiddenInput.inputList[2].fieldRow[0],
405405
);
406406
});
407+
test('from icon to icon', function () {
408+
this.blocks.statementInput1.setCommentText('test');
409+
this.blocks.statementInput1.setWarningText('test');
410+
const icons = this.blocks.statementInput1.getIcons();
411+
const nextNode = this.navigator.getNextSibling(icons[0]);
412+
assert.equal(nextNode, icons[1]);
413+
});
414+
test('from icon to field', function () {
415+
this.blocks.statementInput1.setCommentText('test');
416+
this.blocks.statementInput1.setWarningText('test');
417+
const icons = this.blocks.statementInput1.getIcons();
418+
const nextNode = this.navigator.getNextSibling(icons[1]);
419+
assert.equal(
420+
nextNode,
421+
this.blocks.statementInput1.inputList[0].fieldRow[0],
422+
);
423+
});
424+
test('from icon to null', function () {
425+
this.blocks.dummyInput.setCommentText('test');
426+
const icons = this.blocks.dummyInput.getIcons();
427+
const nextNode = this.navigator.getNextSibling(icons[0]);
428+
assert.isNull(nextNode);
429+
});
407430
});
408431

409432
suite('Previous', function () {
@@ -496,6 +519,28 @@ suite('Navigation', function () {
496519
this.blocks.hiddenInput.inputList[0].fieldRow[0],
497520
);
498521
});
522+
test('from icon to icon', function () {
523+
this.blocks.statementInput1.setCommentText('test');
524+
this.blocks.statementInput1.setWarningText('test');
525+
const icons = this.blocks.statementInput1.getIcons();
526+
const prevNode = this.navigator.getPreviousSibling(icons[1]);
527+
assert.equal(prevNode, icons[0]);
528+
});
529+
test('from field to icon', function () {
530+
this.blocks.statementInput1.setCommentText('test');
531+
this.blocks.statementInput1.setWarningText('test');
532+
const icons = this.blocks.statementInput1.getIcons();
533+
const prevNode = this.navigator.getPreviousSibling(
534+
this.blocks.statementInput1.inputList[0].fieldRow[0],
535+
);
536+
assert.equal(prevNode, icons[1]);
537+
});
538+
test('from icon to null', function () {
539+
this.blocks.dummyInput.setCommentText('test');
540+
const icons = this.blocks.dummyInput.getIcons();
541+
const prevNode = this.navigator.getPreviousSibling(icons[0]);
542+
assert.isNull(prevNode);
543+
});
499544
});
500545

501546
suite('In', function () {
@@ -564,6 +609,18 @@ suite('Navigation', function () {
564609
const inNode = this.navigator.getFirstChild(this.emptyWorkspace);
565610
assert.isNull(inNode);
566611
});
612+
test('from block to icon', function () {
613+
this.blocks.dummyInput.setCommentText('test');
614+
const icons = this.blocks.dummyInput.getIcons();
615+
const inNode = this.navigator.getFirstChild(this.blocks.dummyInput);
616+
assert.equal(inNode, icons[0]);
617+
});
618+
test('from icon to null', function () {
619+
this.blocks.dummyInput.setCommentText('test');
620+
const icons = this.blocks.dummyInput.getIcons();
621+
const inNode = this.navigator.getFirstChild(icons[0]);
622+
assert.isNull(inNode);
623+
});
567624
});
568625

569626
suite('Out', function () {
@@ -661,6 +718,12 @@ suite('Navigation', function () {
661718
const outNode = this.navigator.getParent(this.blocks.outputNextBlock);
662719
assert.equal(outNode, inputConnection);
663720
});
721+
test('from icon to block', function () {
722+
this.blocks.dummyInput.setCommentText('test');
723+
const icons = this.blocks.dummyInput.getIcons();
724+
const outNode = this.navigator.getParent(icons[0]);
725+
assert.equal(outNode, this.blocks.dummyInput);
726+
});
664727
});
665728
});
666729
});

0 commit comments

Comments
 (0)