mirror of
https://github.com/google/blockly.git
synced 2026-01-08 01:20:12 +01:00
fix: Improve narration and navigation of C-shaped blocks. (#9416)
* fix: Improve narration and navigation of C-shaped blocks. * chore: Satisfy the linter. * chore: Refactor and comment `getBlockNavigationCandidates()`. * refactor: Reduce code duplication in `LineCursor`. * fix: Add missing case when labeling connections.
This commit is contained in:
@@ -39,6 +39,7 @@ import {IconType} from './icons/icon_types.js';
|
||||
import {MutatorIcon} from './icons/mutator_icon.js';
|
||||
import {WarningIcon} from './icons/warning_icon.js';
|
||||
import type {Input} from './inputs/input.js';
|
||||
import {inputTypes} from './inputs/input_types.js';
|
||||
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
|
||||
import {IContextMenu} from './interfaces/i_contextmenu.js';
|
||||
import type {ICopyable} from './interfaces/i_copyable.js';
|
||||
@@ -267,6 +268,20 @@ export class BlockSvg
|
||||
blockTypeText = 'C-shaped block';
|
||||
}
|
||||
|
||||
let prefix = '';
|
||||
const parentInput = (
|
||||
this.previousConnection ?? this.outputConnection
|
||||
)?.targetConnection?.getParentInput();
|
||||
if (parentInput && parentInput.type === inputTypes.STATEMENT) {
|
||||
prefix = `Begin ${parentInput.getFieldRowLabel()}, `;
|
||||
} else if (
|
||||
parentInput &&
|
||||
parentInput.type === inputTypes.VALUE &&
|
||||
this.getParent()?.statementInputCount
|
||||
) {
|
||||
prefix = `${parentInput.getFieldRowLabel()} `;
|
||||
}
|
||||
|
||||
let additionalInfo = blockTypeText;
|
||||
if (inputSummary && !nestedStatementBlockCount) {
|
||||
additionalInfo = `${additionalInfo} with ${inputSummary}`;
|
||||
@@ -279,7 +294,7 @@ export class BlockSvg
|
||||
}
|
||||
}
|
||||
|
||||
return blockSummary + ', ' + additionalInfo;
|
||||
return prefix + blockSummary + ', ' + additionalInfo;
|
||||
}
|
||||
|
||||
private computeAriaRole() {
|
||||
|
||||
@@ -303,6 +303,21 @@ export class Input {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label for this input's row on its parent block.
|
||||
*
|
||||
* Generally this consists of the labels/values of the preceding fields, and
|
||||
* is intended for accessibility descriptions.
|
||||
*
|
||||
* @internal
|
||||
* @returns A description of this input's row on its parent block.
|
||||
*/
|
||||
getFieldRowLabel() {
|
||||
return this.fieldRow.reduce((label, field) => {
|
||||
return `${label} ${field.EDITABLE ? field.getAriaName() : field.getValue()}`;
|
||||
}, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a connection based on the type of this input's source block.
|
||||
* Properly handles constructing headless connections for headless blocks
|
||||
|
||||
@@ -124,24 +124,48 @@ function getBlockNavigationCandidates(
|
||||
|
||||
for (const input of block.inputList) {
|
||||
if (!input.isVisible()) continue;
|
||||
|
||||
candidates.push(...input.fieldRow);
|
||||
if (input.connection?.targetBlock()) {
|
||||
const connectedBlock = input.connection.targetBlock() as BlockSvg;
|
||||
if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) {
|
||||
|
||||
const connection = input.connection as RenderedConnection | null;
|
||||
if (!connection) continue;
|
||||
|
||||
const connectedBlock = connection.targetBlock();
|
||||
if (connectedBlock) {
|
||||
if (connection.type === ConnectionType.NEXT_STATEMENT && !forward) {
|
||||
const lastStackBlock = connectedBlock
|
||||
.lastConnectionInStack(false)
|
||||
?.getSourceBlock();
|
||||
if (lastStackBlock) {
|
||||
// When navigating backward, the last block in a stack in a statement
|
||||
// input is navigable.
|
||||
candidates.push(lastStackBlock);
|
||||
}
|
||||
} else {
|
||||
// When navigating forward, a child block connected to a statement
|
||||
// input is navigable.
|
||||
candidates.push(connectedBlock);
|
||||
}
|
||||
} else if (input.connection?.type === ConnectionType.INPUT_VALUE) {
|
||||
candidates.push(input.connection as RenderedConnection);
|
||||
} else if (
|
||||
connection.type === ConnectionType.INPUT_VALUE ||
|
||||
connection.type === ConnectionType.NEXT_STATEMENT
|
||||
) {
|
||||
// Empty input or statement connections are navigable.
|
||||
candidates.push(connection);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
block.nextConnection &&
|
||||
!block.nextConnection.targetBlock() &&
|
||||
(block.lastConnectionInStack(true) !== block.nextConnection ||
|
||||
!!block.getSurroundParent())
|
||||
) {
|
||||
// The empty next connection on the last block in a stack inside of a
|
||||
// statement input is navigable.
|
||||
candidates.push(block.nextConnection);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,24 @@
|
||||
|
||||
import {BlockSvg} from '../block_svg.js';
|
||||
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
|
||||
import {ConnectionType} from '../connection_type.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import * as registry from '../registry.js';
|
||||
import {RenderedConnection} from '../rendered_connection.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {Marker} from './marker.js';
|
||||
|
||||
/**
|
||||
* Representation of the direction of travel within a navigation context.
|
||||
*/
|
||||
export enum NavigationDirection {
|
||||
NEXT,
|
||||
PREVIOUS,
|
||||
IN,
|
||||
OUT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for a line cursor.
|
||||
*/
|
||||
@@ -51,13 +63,7 @@ export class LineCursor extends Marker {
|
||||
}
|
||||
const newNode = this.getNextNode(
|
||||
curNode,
|
||||
(candidate: IFocusableNode | null) => {
|
||||
return (
|
||||
(candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()) ||
|
||||
candidate instanceof RenderedWorkspaceComment
|
||||
);
|
||||
},
|
||||
this.getValidationFunction(NavigationDirection.NEXT),
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -80,7 +86,11 @@ export class LineCursor extends Marker {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newNode = this.getNextNode(curNode, () => true, true);
|
||||
const newNode = this.getNextNode(
|
||||
curNode,
|
||||
this.getValidationFunction(NavigationDirection.IN),
|
||||
true,
|
||||
);
|
||||
|
||||
if (newNode) {
|
||||
this.setCurNode(newNode);
|
||||
@@ -101,13 +111,7 @@ export class LineCursor extends Marker {
|
||||
}
|
||||
const newNode = this.getPreviousNode(
|
||||
curNode,
|
||||
(candidate: IFocusableNode | null) => {
|
||||
return (
|
||||
(candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()) ||
|
||||
candidate instanceof RenderedWorkspaceComment
|
||||
);
|
||||
},
|
||||
this.getValidationFunction(NavigationDirection.PREVIOUS),
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -130,7 +134,11 @@ export class LineCursor extends Marker {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newNode = this.getPreviousNode(curNode, () => true, true);
|
||||
const newNode = this.getPreviousNode(
|
||||
curNode,
|
||||
this.getValidationFunction(NavigationDirection.OUT),
|
||||
true,
|
||||
);
|
||||
|
||||
if (newNode) {
|
||||
this.setCurNode(newNode);
|
||||
@@ -147,15 +155,14 @@ export class LineCursor extends Marker {
|
||||
atEndOfLine(): boolean {
|
||||
const curNode = this.getCurNode();
|
||||
if (!curNode) return false;
|
||||
const inNode = this.getNextNode(curNode, () => true, true);
|
||||
const inNode = this.getNextNode(
|
||||
curNode,
|
||||
this.getValidationFunction(NavigationDirection.IN),
|
||||
true,
|
||||
);
|
||||
const nextNode = this.getNextNode(
|
||||
curNode,
|
||||
(candidate: IFocusableNode | null) => {
|
||||
return (
|
||||
candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()
|
||||
);
|
||||
},
|
||||
this.getValidationFunction(NavigationDirection.NEXT),
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -298,6 +305,92 @@ export class LineCursor extends Marker {
|
||||
return this.getRightMostChild(newNode, stopIfFound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function that will be used to determine whether a candidate for
|
||||
* navigation is valid.
|
||||
*
|
||||
* @param direction The direction in which the user is navigating.
|
||||
* @returns A function that takes a proposed navigation candidate and returns
|
||||
* true if navigation should be allowed to proceed to it, or false to find
|
||||
* a different candidate.
|
||||
*/
|
||||
getValidationFunction(
|
||||
direction: NavigationDirection,
|
||||
): (node: IFocusableNode | null) => boolean {
|
||||
switch (direction) {
|
||||
case NavigationDirection.IN:
|
||||
case NavigationDirection.OUT:
|
||||
return () => true;
|
||||
case NavigationDirection.NEXT:
|
||||
case NavigationDirection.PREVIOUS:
|
||||
return (candidate: IFocusableNode | null) => {
|
||||
if (
|
||||
(candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()) ||
|
||||
candidate instanceof RenderedWorkspaceComment ||
|
||||
(candidate instanceof RenderedConnection &&
|
||||
(candidate.type === ConnectionType.NEXT_STATEMENT ||
|
||||
(candidate.type === ConnectionType.INPUT_VALUE &&
|
||||
candidate.getSourceBlock().statementInputCount &&
|
||||
candidate.getSourceBlock().inputList[0] !==
|
||||
candidate.getParentInput())))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const current = this.getSourceBlockFromNode(this.getCurNode());
|
||||
if (candidate instanceof BlockSvg && current instanceof BlockSvg) {
|
||||
// If the candidate's parent uses inline inputs, disallow the
|
||||
// candidate; it follows that it must be on the same row as its
|
||||
// parent.
|
||||
if (candidate.outputConnection?.targetBlock()?.getInputsInline()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidateParents = this.getParents(candidate);
|
||||
// If the candidate block is an (in)direct child of the current
|
||||
// block, disallow it; it cannot be on a different row than the
|
||||
// current block.
|
||||
if (
|
||||
current === this.getCurNode() &&
|
||||
candidateParents.has(current)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentParents = this.getParents(current);
|
||||
|
||||
const sharedParents = currentParents.intersection(candidateParents);
|
||||
// Allow the candidate if it and the current block have no parents
|
||||
// in common, or if they have a shared parent with external inputs.
|
||||
const result =
|
||||
!sharedParents.size ||
|
||||
sharedParents.values().some((block) => !block.getInputsInline());
|
||||
return result;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a set of all of the parent blocks of the given block.
|
||||
*
|
||||
* @param block The block to retrieve the parents of.
|
||||
* @returns A set of the parents of the given block.
|
||||
*/
|
||||
private getParents(block: BlockSvg): Set<BlockSvg> {
|
||||
const parents = new Set<BlockSvg>();
|
||||
let parent = block.getParent();
|
||||
while (parent) {
|
||||
parents.add(parent);
|
||||
parent = parent.getParent();
|
||||
}
|
||||
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare for the deletion of a block by making a list of nodes we
|
||||
* could move the cursor to afterwards and save it to
|
||||
|
||||
@@ -20,6 +20,7 @@ import {ConnectionType} from './connection_type.js';
|
||||
import * as ContextMenu from './contextmenu.js';
|
||||
import {ContextMenuRegistry} from './contextmenu_registry.js';
|
||||
import * as eventUtils from './events/utils.js';
|
||||
import {inputTypes} from './inputs/input_types.js';
|
||||
import {IContextMenu} from './interfaces/i_contextmenu.js';
|
||||
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
|
||||
@@ -334,7 +335,32 @@ export class RenderedConnection
|
||||
if (highlightSvg) {
|
||||
highlightSvg.style.display = '';
|
||||
aria.setRole(highlightSvg, aria.Role.FIGURE);
|
||||
aria.setState(highlightSvg, aria.State.LABEL, 'Open connection');
|
||||
aria.setState(highlightSvg, aria.State.ROLEDESCRIPTION, 'Connection');
|
||||
if (this.type === ConnectionType.NEXT_STATEMENT) {
|
||||
const parentInput =
|
||||
this.getParentInput() ??
|
||||
this.getSourceBlock()
|
||||
.getTopStackBlock()
|
||||
.previousConnection?.targetConnection?.getParentInput();
|
||||
if (parentInput && parentInput.type === inputTypes.STATEMENT) {
|
||||
aria.setState(
|
||||
highlightSvg,
|
||||
aria.State.LABEL,
|
||||
`${this.getParentInput() ? 'Begin' : 'End'} ${parentInput.getFieldRowLabel()}`,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
this.type === ConnectionType.INPUT_VALUE &&
|
||||
this.getSourceBlock().statementInputCount
|
||||
) {
|
||||
aria.setState(
|
||||
highlightSvg,
|
||||
aria.State.LABEL,
|
||||
`${this.getParentInput()?.getFieldRowLabel()}`,
|
||||
);
|
||||
} else {
|
||||
aria.setState(highlightSvg, aria.State.LABEL, 'Open connection');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user