Skip to content

Commit 40aa0d3

Browse files
authored
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.
1 parent 9d85f9b commit 40aa0d3

File tree

8 files changed

+225
-42
lines changed

8 files changed

+225
-42
lines changed

core/block_svg.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {IconType} from './icons/icon_types.js';
3939
import {MutatorIcon} from './icons/mutator_icon.js';
4040
import {WarningIcon} from './icons/warning_icon.js';
4141
import type {Input} from './inputs/input.js';
42+
import {inputTypes} from './inputs/input_types.js';
4243
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
4344
import {IContextMenu} from './interfaces/i_contextmenu.js';
4445
import type {ICopyable} from './interfaces/i_copyable.js';
@@ -267,6 +268,20 @@ export class BlockSvg
267268
blockTypeText = 'C-shaped block';
268269
}
269270

271+
let prefix = '';
272+
const parentInput = (
273+
this.previousConnection ?? this.outputConnection
274+
)?.targetConnection?.getParentInput();
275+
if (parentInput && parentInput.type === inputTypes.STATEMENT) {
276+
prefix = `Begin ${parentInput.getFieldRowLabel()}, `;
277+
} else if (
278+
parentInput &&
279+
parentInput.type === inputTypes.VALUE &&
280+
this.getParent()?.statementInputCount
281+
) {
282+
prefix = `${parentInput.getFieldRowLabel()} `;
283+
}
284+
270285
let additionalInfo = blockTypeText;
271286
if (inputSummary && !nestedStatementBlockCount) {
272287
additionalInfo = `${additionalInfo} with ${inputSummary}`;
@@ -279,7 +294,7 @@ export class BlockSvg
279294
}
280295
}
281296

282-
return blockSummary + ', ' + additionalInfo;
297+
return prefix + blockSummary + ', ' + additionalInfo;
283298
}
284299

285300
private computeAriaRole() {

core/inputs/input.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,21 @@ export class Input {
303303
}
304304
}
305305

306+
/**
307+
* Returns a label for this input's row on its parent block.
308+
*
309+
* Generally this consists of the labels/values of the preceding fields, and
310+
* is intended for accessibility descriptions.
311+
*
312+
* @internal
313+
* @returns A description of this input's row on its parent block.
314+
*/
315+
getFieldRowLabel() {
316+
return this.fieldRow.reduce((label, field) => {
317+
return `${label} ${field.EDITABLE ? field.getAriaName() : field.getValue()}`;
318+
}, '');
319+
}
320+
306321
/**
307322
* Constructs a connection based on the type of this input's source block.
308323
* Properly handles constructing headless connections for headless blocks

core/keyboard_nav/block_navigation_policy.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,24 +124,48 @@ function getBlockNavigationCandidates(
124124

125125
for (const input of block.inputList) {
126126
if (!input.isVisible()) continue;
127+
127128
candidates.push(...input.fieldRow);
128-
if (input.connection?.targetBlock()) {
129-
const connectedBlock = input.connection.targetBlock() as BlockSvg;
130-
if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) {
129+
130+
const connection = input.connection as RenderedConnection | null;
131+
if (!connection) continue;
132+
133+
const connectedBlock = connection.targetBlock();
134+
if (connectedBlock) {
135+
if (connection.type === ConnectionType.NEXT_STATEMENT && !forward) {
131136
const lastStackBlock = connectedBlock
132137
.lastConnectionInStack(false)
133138
?.getSourceBlock();
134139
if (lastStackBlock) {
140+
// When navigating backward, the last block in a stack in a statement
141+
// input is navigable.
135142
candidates.push(lastStackBlock);
136143
}
137144
} else {
145+
// When navigating forward, a child block connected to a statement
146+
// input is navigable.
138147
candidates.push(connectedBlock);
139148
}
140-
} else if (input.connection?.type === ConnectionType.INPUT_VALUE) {
141-
candidates.push(input.connection as RenderedConnection);
149+
} else if (
150+
connection.type === ConnectionType.INPUT_VALUE ||
151+
connection.type === ConnectionType.NEXT_STATEMENT
152+
) {
153+
// Empty input or statement connections are navigable.
154+
candidates.push(connection);
142155
}
143156
}
144157

158+
if (
159+
block.nextConnection &&
160+
!block.nextConnection.targetBlock() &&
161+
(block.lastConnectionInStack(true) !== block.nextConnection ||
162+
!!block.getSurroundParent())
163+
) {
164+
// The empty next connection on the last block in a stack inside of a
165+
// statement input is navigable.
166+
candidates.push(block.nextConnection);
167+
}
168+
145169
return candidates;
146170
}
147171

core/keyboard_nav/line_cursor.ts

Lines changed: 116 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,24 @@
1515

1616
import {BlockSvg} from '../block_svg.js';
1717
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
18+
import {ConnectionType} from '../connection_type.js';
1819
import {getFocusManager} from '../focus_manager.js';
1920
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
2021
import * as registry from '../registry.js';
22+
import {RenderedConnection} from '../rendered_connection.js';
2123
import type {WorkspaceSvg} from '../workspace_svg.js';
2224
import {Marker} from './marker.js';
2325

26+
/**
27+
* Representation of the direction of travel within a navigation context.
28+
*/
29+
export enum NavigationDirection {
30+
NEXT,
31+
PREVIOUS,
32+
IN,
33+
OUT,
34+
}
35+
2436
/**
2537
* Class for a line cursor.
2638
*/
@@ -51,13 +63,7 @@ export class LineCursor extends Marker {
5163
}
5264
const newNode = this.getNextNode(
5365
curNode,
54-
(candidate: IFocusableNode | null) => {
55-
return (
56-
(candidate instanceof BlockSvg &&
57-
!candidate.outputConnection?.targetBlock()) ||
58-
candidate instanceof RenderedWorkspaceComment
59-
);
60-
},
66+
this.getValidationFunction(NavigationDirection.NEXT),
6167
true,
6268
);
6369

@@ -80,7 +86,11 @@ export class LineCursor extends Marker {
8086
return null;
8187
}
8288

83-
const newNode = this.getNextNode(curNode, () => true, true);
89+
const newNode = this.getNextNode(
90+
curNode,
91+
this.getValidationFunction(NavigationDirection.IN),
92+
true,
93+
);
8494

8595
if (newNode) {
8696
this.setCurNode(newNode);
@@ -101,13 +111,7 @@ export class LineCursor extends Marker {
101111
}
102112
const newNode = this.getPreviousNode(
103113
curNode,
104-
(candidate: IFocusableNode | null) => {
105-
return (
106-
(candidate instanceof BlockSvg &&
107-
!candidate.outputConnection?.targetBlock()) ||
108-
candidate instanceof RenderedWorkspaceComment
109-
);
110-
},
114+
this.getValidationFunction(NavigationDirection.PREVIOUS),
111115
true,
112116
);
113117

@@ -130,7 +134,11 @@ export class LineCursor extends Marker {
130134
return null;
131135
}
132136

133-
const newNode = this.getPreviousNode(curNode, () => true, true);
137+
const newNode = this.getPreviousNode(
138+
curNode,
139+
this.getValidationFunction(NavigationDirection.OUT),
140+
true,
141+
);
134142

135143
if (newNode) {
136144
this.setCurNode(newNode);
@@ -147,15 +155,14 @@ export class LineCursor extends Marker {
147155
atEndOfLine(): boolean {
148156
const curNode = this.getCurNode();
149157
if (!curNode) return false;
150-
const inNode = this.getNextNode(curNode, () => true, true);
158+
const inNode = this.getNextNode(
159+
curNode,
160+
this.getValidationFunction(NavigationDirection.IN),
161+
true,
162+
);
151163
const nextNode = this.getNextNode(
152164
curNode,
153-
(candidate: IFocusableNode | null) => {
154-
return (
155-
candidate instanceof BlockSvg &&
156-
!candidate.outputConnection?.targetBlock()
157-
);
158-
},
165+
this.getValidationFunction(NavigationDirection.NEXT),
159166
true,
160167
);
161168

@@ -298,6 +305,92 @@ export class LineCursor extends Marker {
298305
return this.getRightMostChild(newNode, stopIfFound);
299306
}
300307

308+
/**
309+
* Returns a function that will be used to determine whether a candidate for
310+
* navigation is valid.
311+
*
312+
* @param direction The direction in which the user is navigating.
313+
* @returns A function that takes a proposed navigation candidate and returns
314+
* true if navigation should be allowed to proceed to it, or false to find
315+
* a different candidate.
316+
*/
317+
getValidationFunction(
318+
direction: NavigationDirection,
319+
): (node: IFocusableNode | null) => boolean {
320+
switch (direction) {
321+
case NavigationDirection.IN:
322+
case NavigationDirection.OUT:
323+
return () => true;
324+
case NavigationDirection.NEXT:
325+
case NavigationDirection.PREVIOUS:
326+
return (candidate: IFocusableNode | null) => {
327+
if (
328+
(candidate instanceof BlockSvg &&
329+
!candidate.outputConnection?.targetBlock()) ||
330+
candidate instanceof RenderedWorkspaceComment ||
331+
(candidate instanceof RenderedConnection &&
332+
(candidate.type === ConnectionType.NEXT_STATEMENT ||
333+
(candidate.type === ConnectionType.INPUT_VALUE &&
334+
candidate.getSourceBlock().statementInputCount &&
335+
candidate.getSourceBlock().inputList[0] !==
336+
candidate.getParentInput())))
337+
) {
338+
return true;
339+
}
340+
341+
const current = this.getSourceBlockFromNode(this.getCurNode());
342+
if (candidate instanceof BlockSvg && current instanceof BlockSvg) {
343+
// If the candidate's parent uses inline inputs, disallow the
344+
// candidate; it follows that it must be on the same row as its
345+
// parent.
346+
if (candidate.outputConnection?.targetBlock()?.getInputsInline()) {
347+
return false;
348+
}
349+
350+
const candidateParents = this.getParents(candidate);
351+
// If the candidate block is an (in)direct child of the current
352+
// block, disallow it; it cannot be on a different row than the
353+
// current block.
354+
if (
355+
current === this.getCurNode() &&
356+
candidateParents.has(current)
357+
) {
358+
return false;
359+
}
360+
361+
const currentParents = this.getParents(current);
362+
363+
const sharedParents = currentParents.intersection(candidateParents);
364+
// Allow the candidate if it and the current block have no parents
365+
// in common, or if they have a shared parent with external inputs.
366+
const result =
367+
!sharedParents.size ||
368+
sharedParents.values().some((block) => !block.getInputsInline());
369+
return result;
370+
}
371+
372+
return false;
373+
};
374+
}
375+
}
376+
377+
/**
378+
* Returns a set of all of the parent blocks of the given block.
379+
*
380+
* @param block The block to retrieve the parents of.
381+
* @returns A set of the parents of the given block.
382+
*/
383+
private getParents(block: BlockSvg): Set<BlockSvg> {
384+
const parents = new Set<BlockSvg>();
385+
let parent = block.getParent();
386+
while (parent) {
387+
parents.add(parent);
388+
parent = parent.getParent();
389+
}
390+
391+
return parents;
392+
}
393+
301394
/**
302395
* Prepare for the deletion of a block by making a list of nodes we
303396
* could move the cursor to afterwards and save it to

core/rendered_connection.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {ConnectionType} from './connection_type.js';
2020
import * as ContextMenu from './contextmenu.js';
2121
import {ContextMenuRegistry} from './contextmenu_registry.js';
2222
import * as eventUtils from './events/utils.js';
23+
import {inputTypes} from './inputs/input_types.js';
2324
import {IContextMenu} from './interfaces/i_contextmenu.js';
2425
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
2526
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
@@ -334,7 +335,32 @@ export class RenderedConnection
334335
if (highlightSvg) {
335336
highlightSvg.style.display = '';
336337
aria.setRole(highlightSvg, aria.Role.FIGURE);
337-
aria.setState(highlightSvg, aria.State.LABEL, 'Open connection');
338+
aria.setState(highlightSvg, aria.State.ROLEDESCRIPTION, 'Connection');
339+
if (this.type === ConnectionType.NEXT_STATEMENT) {
340+
const parentInput =
341+
this.getParentInput() ??
342+
this.getSourceBlock()
343+
.getTopStackBlock()
344+
.previousConnection?.targetConnection?.getParentInput();
345+
if (parentInput && parentInput.type === inputTypes.STATEMENT) {
346+
aria.setState(
347+
highlightSvg,
348+
aria.State.LABEL,
349+
`${this.getParentInput() ? 'Begin' : 'End'} ${parentInput.getFieldRowLabel()}`,
350+
);
351+
}
352+
} else if (
353+
this.type === ConnectionType.INPUT_VALUE &&
354+
this.getSourceBlock().statementInputCount
355+
) {
356+
aria.setState(
357+
highlightSvg,
358+
aria.State.LABEL,
359+
`${this.getParentInput()?.getFieldRowLabel()}`,
360+
);
361+
} else {
362+
aria.setState(highlightSvg, aria.State.LABEL, 'Open connection');
363+
}
338364
}
339365
}
340366

0 commit comments

Comments
 (0)