Skip to content

Commit ed92d50

Browse files
committed
fix: Improve narration and navigation of C-shaped blocks.
1 parent 9d85f9b commit ed92d50

8 files changed

Lines changed: 199 additions & 37 deletions

File tree

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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,20 @@ 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+
* @internal
312+
* @returns A description of this input's row on its parent block.
313+
*/
314+
getFieldRowLabel() {
315+
return this.fieldRow.reduce((label, field) => {
316+
return `${label} ${field.EDITABLE ? field.getAriaName() : field.getValue()}`;
317+
}, '');
318+
}
319+
306320
/**
307321
* Constructs a connection based on the type of this input's source block.
308322
* Properly handles constructing headless connections for headless blocks

core/keyboard_nav/block_navigation_policy.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ function getBlockNavigationCandidates(
125125
for (const input of block.inputList) {
126126
if (!input.isVisible()) continue;
127127
candidates.push(...input.fieldRow);
128+
if (
129+
input.connection?.type === ConnectionType.NEXT_STATEMENT &&
130+
!input.connection.targetBlock()
131+
) {
132+
candidates.push(input.connection as RenderedConnection);
133+
}
134+
128135
if (input.connection?.targetBlock()) {
129136
const connectedBlock = input.connection.targetBlock() as BlockSvg;
130137
if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) {
@@ -142,6 +149,15 @@ function getBlockNavigationCandidates(
142149
}
143150
}
144151

152+
if (
153+
block.nextConnection &&
154+
!block.nextConnection.targetBlock() &&
155+
(block.lastConnectionInStack(true) !== block.nextConnection ||
156+
!!block.getSurroundParent())
157+
) {
158+
candidates.push(block.nextConnection);
159+
}
160+
145161
return candidates;
146162
}
147163

core/keyboard_nav/line_cursor.ts

Lines changed: 106 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,82 @@ 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 = new Set<BlockSvg>();
351+
let parent = candidate.getParent();
352+
while (parent) {
353+
// If the candidate block is an (in)direct child of the current
354+
// block, disallow it; it cannot be on a different row than the
355+
// current block.
356+
if (parent === current && current === this.getCurNode()) {
357+
return false;
358+
}
359+
candidateParents.add(parent);
360+
parent = parent.getParent();
361+
}
362+
363+
const currentParents = new Set<BlockSvg>();
364+
parent = current.getParent();
365+
while (parent) {
366+
currentParents.add(parent);
367+
parent = parent.getParent();
368+
}
369+
370+
const sharedParents = currentParents.intersection(candidateParents);
371+
// Allow the candidate if it and the current block have no parents
372+
// in common, or if they have a shared parent with external inputs.
373+
const result =
374+
!sharedParents.size ||
375+
sharedParents.values().some((block) => !block.getInputsInline());
376+
return result;
377+
}
378+
379+
return false;
380+
};
381+
}
382+
}
383+
301384
/**
302385
* Prepare for the deletion of a block by making a list of nodes we
303386
* could move the cursor to afterwards and save it to

core/rendered_connection.ts

Lines changed: 25 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,30 @@ 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+
}
338362
}
339363
}
340364

tests/mocha/cursor_test.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,22 +136,22 @@ suite('Cursor', function () {
136136
assert.equal(curNode, fieldBlock);
137137
});
138138

139-
test('Prev - From previous connection does skip over next connection', function () {
139+
test('Prev - From previous connection does not skip over next connection', function () {
140140
const prevConnection = this.blocks.B.previousConnection;
141141
const prevConnectionNode = prevConnection;
142142
this.cursor.setCurNode(prevConnectionNode);
143143
this.cursor.prev();
144144
const curNode = this.cursor.getCurNode();
145-
assert.equal(curNode, this.blocks.A);
145+
assert.equal(curNode, this.blocks.A.nextConnection);
146146
});
147147

148-
test('Prev - From first block loop to last block', function () {
148+
test('Prev - From first block loop to last statement input', function () {
149149
const prevConnection = this.blocks.A;
150150
const prevConnectionNode = prevConnection;
151151
this.cursor.setCurNode(prevConnectionNode);
152152
this.cursor.prev();
153153
const curNode = this.cursor.getCurNode();
154-
assert.equal(curNode, this.blocks.D);
154+
assert.equal(curNode, this.blocks.D.getInput('NAME4').connection);
155155
});
156156

157157
test('Out - From field does not skip over block node', function () {
@@ -253,26 +253,34 @@ suite('Cursor', function () {
253253
test('In - from field in nested statement block to next nested statement block', function () {
254254
this.cursor.setCurNode(this.secondStatement.getField('NAME'));
255255
this.cursor.in();
256+
// Skip over the next connection
257+
this.cursor.in();
256258
const curNode = this.cursor.getCurNode();
257259
assert.equal(curNode, this.thirdStatement);
258260
});
259261
test('In - from field in nested statement block to next stack', function () {
260262
this.cursor.setCurNode(this.thirdStatement.getField('NAME'));
261263
this.cursor.in();
264+
// Skip over the next connection
265+
this.cursor.in();
262266
const curNode = this.cursor.getCurNode();
263267
assert.equal(curNode, this.multiStatement2);
264268
});
265269

266270
test('Out - from nested statement block to last field of previous nested statement block', function () {
267271
this.cursor.setCurNode(this.thirdStatement);
268272
this.cursor.out();
273+
// Skip over the previous next connection
274+
this.cursor.out();
269275
const curNode = this.cursor.getCurNode();
270276
assert.equal(curNode, this.secondStatement.getField('NAME'));
271277
});
272278

273279
test('Out - from root block to last field of last nested statement block in previous stack', function () {
274280
this.cursor.setCurNode(this.multiStatement2);
275281
this.cursor.out();
282+
// Skip over the previous next connection
283+
this.cursor.out();
276284
const curNode = this.cursor.getCurNode();
277285
assert.equal(curNode, this.thirdStatement.getField('NAME'));
278286
});
@@ -395,7 +403,7 @@ suite('Cursor', function () {
395403
});
396404
test('getLastNode', function () {
397405
const node = this.cursor.getLastNode();
398-
assert.equal(node, this.blockA);
406+
assert.equal(node, this.blockA.inputList[0].connection);
399407
});
400408
});
401409

0 commit comments

Comments
 (0)