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 <matt.hillsdon@microbit.org>
This commit is contained in:
Robert Knight
2025-10-06 21:44:01 +01:00
committed by GitHub
parent 0eec0e0cd6
commit b74ebe200f

View File

@@ -218,12 +218,11 @@ export class BlockSvg
// The page-wide unique ID of this Block used for focusing.
svgPath.id = idGenerator.getNextUniqueId();
aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block');
aria.setRole(svgPath, aria.Role.TREEITEM);
svgPath.tabIndex = -1;
this.currentConnectionCandidate = null;
this.doInit_();
this.computeAriaRole();
}
private recomputeAriaLabel() {
@@ -235,29 +234,67 @@ export class BlockSvg
}
private computeAriaLabel(): string {
// Guess the block's aria label based on its field labels.
if (this.isShadow() || this.isSimpleReporter()) {
// TODO: Shadows may have more than one field.
// Shadow blocks are best represented directly by their field since they
// effectively operate like a field does for keyboard navigation purposes.
const field = Array.from(this.getFields())[0];
try {
return (
aria.getState(field.getFocusableElement(), aria.State.LABEL) ??
'Unknown?'
);
} catch {
return 'Unknown?';
const {blockSummary, inputCount} = buildBlockSummary(this);
const inputSummary = inputCount
? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}`
: '';
let currentBlock: Block | null = null;
let nestedStatementBlockCount = 0;
// This won't work well for if/else blocks.
this.inputList.forEach((input) => {
if (
input.connection &&
input.connection.type === ConnectionType.NEXT_STATEMENT
) {
currentBlock = input.connection.targetBlock();
}
});
// The type is poorly inferred here.
while (currentBlock as Block | null) {
nestedStatementBlockCount++;
// The type is poorly inferred here.
// If currentBlock is null, we can't enter this while loop...
currentBlock = currentBlock!.getNextBlock();
}
let blockTypeText = 'block';
if (this.isShadow()) {
blockTypeText = 'input block';
} else if (this.outputConnection) {
blockTypeText = 'replacable block';
} else if (this.statementInputCount) {
blockTypeText = 'C-shaped block';
}
let additionalInfo = blockTypeText;
if (inputSummary && !nestedStatementBlockCount) {
additionalInfo = `${additionalInfo} with ${inputSummary}`;
} else if (nestedStatementBlockCount) {
const childBlockSummary = `${nestedStatementBlockCount} child ${nestedStatementBlockCount > 1 ? 'blocks' : 'block'}`;
if (inputSummary) {
additionalInfo = `${additionalInfo} with ${inputSummary} and ${childBlockSummary}`;
} else {
additionalInfo = `${additionalInfo} with ${childBlockSummary}`;
}
}
const fieldLabels = [];
for (const field of this.getFields()) {
if (field instanceof FieldLabel) {
fieldLabels.push(field.getText());
}
return blockSummary + ', ' + additionalInfo;
}
private computeAriaRole() {
if (this.isSimpleReporter()) {
aria.setRole(this.pathObject.svgPath, aria.Role.BUTTON);
} else {
// This isn't read out by VoiceOver and it will read in the wrong place
// as a duplicate in ChromeVox due to the other changes in this branch.
// aria.setState(
// this.pathObject.svgPath,
// aria.State.ROLEDESCRIPTION,
// 'block',
// );
aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM);
}
return fieldLabels.join(' ');
}
collectSiblingBlocks(surroundParent: BlockSvg | null): BlockSvg[] {
@@ -1724,6 +1761,8 @@ export class BlockSvg
* settings.
*/
render() {
this.recomputeAriaLabel();
this.queueRender();
renderManagement.triggerQueuedRenders();
}
@@ -1735,6 +1774,8 @@ export class BlockSvg
* @internal
*/
renderEfficiently() {
this.recomputeAriaLabel();
dom.startTextWidthCache();
if (this.isCollapsed()) {
@@ -1991,3 +2032,51 @@ export class BlockSvg
}
}
}
interface BlockSummary {
blockSummary: string;
inputCount: number;
}
function buildBlockSummary(block: BlockSvg): BlockSummary {
let inputCount = 0;
function recursiveInputSummary(
block: BlockSvg,
isNestedInput: boolean = false,
): string {
return block.inputList
.flatMap((input) => {
const fields = input.fieldRow.map((field) => {
// If the block is a full block field, we only want to know if it's an
// editable field if we're not directly on it.
if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) {
inputCount++;
}
return [field.getText() ?? field.getValue()];
});
if (
input.connection &&
input.connection.type === ConnectionType.INPUT_VALUE
) {
if (!isNestedInput) {
inputCount++;
}
const targetBlock = input.connection.targetBlock();
if (targetBlock) {
return [
...fields,
recursiveInputSummary(targetBlock as BlockSvg, true),
];
}
}
return fields;
})
.join(' ');
}
const blockSummary = recursiveInputSummary(block);
return {
blockSummary,
inputCount,
};
}