mirror of
https://github.com/google/blockly.git
synced 2026-01-30 12:10:12 +01:00
feat: Add keyboard navigation support for icons. (#9072)
* feat: Add keyboard navigation support for icons. * chore: Satisfy the linter.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
96
core/keyboard_nav/icon_navigation_policy.ts
Normal file
96
core/keyboard_nav/icon_navigation_policy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user