Skip to content

Commit b74ebe2

Browse files
Introduce better block labeling for screen readers (#9357)
Read value-inputs and fields in place and recursively. Announce block shape, number of inputs and number of children where appropriate. Co-authored-by: Matt Hillsdon <[email protected]>
1 parent 0eec0e0 commit b74ebe2

File tree

1 file changed

+110
-21
lines changed

1 file changed

+110
-21
lines changed

core/block_svg.ts

Lines changed: 110 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,11 @@ export class BlockSvg
218218
// The page-wide unique ID of this Block used for focusing.
219219
svgPath.id = idGenerator.getNextUniqueId();
220220

221-
aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block');
222-
aria.setRole(svgPath, aria.Role.TREEITEM);
223221
svgPath.tabIndex = -1;
224222
this.currentConnectionCandidate = null;
225223

226224
this.doInit_();
225+
this.computeAriaRole();
227226
}
228227

229228
private recomputeAriaLabel() {
@@ -235,29 +234,67 @@ export class BlockSvg
235234
}
236235

237236
private computeAriaLabel(): string {
238-
// Guess the block's aria label based on its field labels.
239-
if (this.isShadow() || this.isSimpleReporter()) {
240-
// TODO: Shadows may have more than one field.
241-
// Shadow blocks are best represented directly by their field since they
242-
// effectively operate like a field does for keyboard navigation purposes.
243-
const field = Array.from(this.getFields())[0];
244-
try {
245-
return (
246-
aria.getState(field.getFocusableElement(), aria.State.LABEL) ??
247-
'Unknown?'
248-
);
249-
} catch {
250-
return 'Unknown?';
237+
const {blockSummary, inputCount} = buildBlockSummary(this);
238+
const inputSummary = inputCount
239+
? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}`
240+
: '';
241+
242+
let currentBlock: Block | null = null;
243+
let nestedStatementBlockCount = 0;
244+
// This won't work well for if/else blocks.
245+
this.inputList.forEach((input) => {
246+
if (
247+
input.connection &&
248+
input.connection.type === ConnectionType.NEXT_STATEMENT
249+
) {
250+
currentBlock = input.connection.targetBlock();
251+
}
252+
});
253+
// The type is poorly inferred here.
254+
while (currentBlock as Block | null) {
255+
nestedStatementBlockCount++;
256+
// The type is poorly inferred here.
257+
// If currentBlock is null, we can't enter this while loop...
258+
currentBlock = currentBlock!.getNextBlock();
259+
}
260+
261+
let blockTypeText = 'block';
262+
if (this.isShadow()) {
263+
blockTypeText = 'input block';
264+
} else if (this.outputConnection) {
265+
blockTypeText = 'replacable block';
266+
} else if (this.statementInputCount) {
267+
blockTypeText = 'C-shaped block';
268+
}
269+
270+
let additionalInfo = blockTypeText;
271+
if (inputSummary && !nestedStatementBlockCount) {
272+
additionalInfo = `${additionalInfo} with ${inputSummary}`;
273+
} else if (nestedStatementBlockCount) {
274+
const childBlockSummary = `${nestedStatementBlockCount} child ${nestedStatementBlockCount > 1 ? 'blocks' : 'block'}`;
275+
if (inputSummary) {
276+
additionalInfo = `${additionalInfo} with ${inputSummary} and ${childBlockSummary}`;
277+
} else {
278+
additionalInfo = `${additionalInfo} with ${childBlockSummary}`;
251279
}
252280
}
253281

254-
const fieldLabels = [];
255-
for (const field of this.getFields()) {
256-
if (field instanceof FieldLabel) {
257-
fieldLabels.push(field.getText());
258-
}
282+
return blockSummary + ', ' + additionalInfo;
283+
}
284+
285+
private computeAriaRole() {
286+
if (this.isSimpleReporter()) {
287+
aria.setRole(this.pathObject.svgPath, aria.Role.BUTTON);
288+
} else {
289+
// This isn't read out by VoiceOver and it will read in the wrong place
290+
// as a duplicate in ChromeVox due to the other changes in this branch.
291+
// aria.setState(
292+
// this.pathObject.svgPath,
293+
// aria.State.ROLEDESCRIPTION,
294+
// 'block',
295+
// );
296+
aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM);
259297
}
260-
return fieldLabels.join(' ');
261298
}
262299

263300
collectSiblingBlocks(surroundParent: BlockSvg | null): BlockSvg[] {
@@ -1724,6 +1761,8 @@ export class BlockSvg
17241761
* settings.
17251762
*/
17261763
render() {
1764+
this.recomputeAriaLabel();
1765+
17271766
this.queueRender();
17281767
renderManagement.triggerQueuedRenders();
17291768
}
@@ -1735,6 +1774,8 @@ export class BlockSvg
17351774
* @internal
17361775
*/
17371776
renderEfficiently() {
1777+
this.recomputeAriaLabel();
1778+
17381779
dom.startTextWidthCache();
17391780

17401781
if (this.isCollapsed()) {
@@ -1991,3 +2032,51 @@ export class BlockSvg
19912032
}
19922033
}
19932034
}
2035+
2036+
interface BlockSummary {
2037+
blockSummary: string;
2038+
inputCount: number;
2039+
}
2040+
2041+
function buildBlockSummary(block: BlockSvg): BlockSummary {
2042+
let inputCount = 0;
2043+
function recursiveInputSummary(
2044+
block: BlockSvg,
2045+
isNestedInput: boolean = false,
2046+
): string {
2047+
return block.inputList
2048+
.flatMap((input) => {
2049+
const fields = input.fieldRow.map((field) => {
2050+
// If the block is a full block field, we only want to know if it's an
2051+
// editable field if we're not directly on it.
2052+
if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) {
2053+
inputCount++;
2054+
}
2055+
return [field.getText() ?? field.getValue()];
2056+
});
2057+
if (
2058+
input.connection &&
2059+
input.connection.type === ConnectionType.INPUT_VALUE
2060+
) {
2061+
if (!isNestedInput) {
2062+
inputCount++;
2063+
}
2064+
const targetBlock = input.connection.targetBlock();
2065+
if (targetBlock) {
2066+
return [
2067+
...fields,
2068+
recursiveInputSummary(targetBlock as BlockSvg, true),
2069+
];
2070+
}
2071+
}
2072+
return fields;
2073+
})
2074+
.join(' ');
2075+
}
2076+
2077+
const blockSummary = recursiveInputSummary(block);
2078+
return {
2079+
blockSummary,
2080+
inputCount,
2081+
};
2082+
}

0 commit comments

Comments
 (0)