fix: Miscellaneous improvements for screenreader support. (#9424)

* fix: Miscellaneous improvements for screenreader support.

* fix: Include field name in ARIA label.

* fix: Update block ARIA labels when inputs are shown/hidden.

* fix: Make field row label generation more robust.
This commit is contained in:
Aaron Dodson
2025-10-16 14:17:00 -07:00
committed by GitHub
parent c8a7fc66c4
commit 4f475c7302
7 changed files with 105 additions and 58 deletions

View File

@@ -226,7 +226,17 @@ export class BlockSvg
this.computeAriaRole();
}
private recomputeAriaLabel() {
/**
* Updates the ARIA label of this block to reflect its current configuration.
*
* @internal
*/
recomputeAriaLabel() {
if (this.isSimpleReporter()) {
const field = Array.from(this.getFields())[0];
if (field.isFullBlockField() && field.isCurrentlyEditable()) return;
}
aria.setState(
this.getFocusableElement(),
aria.State.LABEL,
@@ -240,34 +250,42 @@ export class BlockSvg
? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}`
: '';
let currentBlock: Block | null = null;
let currentBlock: BlockSvg | null = null;
let nestedStatementBlockCount = 0;
// This won't work well for if/else blocks.
this.inputList.forEach((input) => {
for (const input of this.inputList) {
if (
input.connection &&
input.connection.type === ConnectionType.NEXT_STATEMENT
) {
currentBlock = input.connection.targetBlock();
currentBlock = input.connection.targetBlock() as BlockSvg | null;
while (currentBlock) {
nestedStatementBlockCount++;
currentBlock = currentBlock.getNextBlock();
}
}
});
// 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.outputConnection) {
blockTypeText = 'input block';
} else if (this.statementInputCount) {
blockTypeText = 'C-shaped block';
}
const modifiers = [];
if (!this.isEnabled()) {
modifiers.push('disabled');
}
if (this.isCollapsed()) {
modifiers.push('collapsed');
}
if (modifiers.length) {
blockTypeText = `${modifiers.join(' ')} ${blockTypeText}`;
}
let prefix = '';
const parentInput = (
this.previousConnection ?? this.outputConnection
@@ -298,9 +316,7 @@ export class BlockSvg
}
private computeAriaRole() {
if (this.isSimpleReporter()) {
aria.setRole(this.pathObject.svgPath, aria.Role.BUTTON);
} else if (this.workspace.isFlyout) {
if (this.workspace.isFlyout) {
aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM);
} else {
aria.setState(
@@ -335,8 +351,6 @@ export class BlockSvg
if (!svg.parentNode) {
this.workspace.getCanvas().appendChild(svg);
}
// Note: This must be done after initialization of the block's fields.
this.recomputeAriaLabel();
this.initialized = true;
}
@@ -672,6 +686,7 @@ export class BlockSvg
this.removeInput(collapsedInputName);
dom.removeClass(this.svgGroup, 'blocklyCollapsed');
this.setWarningText(null, BlockSvg.COLLAPSED_WARNING_ID);
this.recomputeAriaLabel();
return;
}
@@ -693,6 +708,8 @@ export class BlockSvg
this.getInput(collapsedInputName) ||
this.appendDummyInput(collapsedInputName);
input.appendField(new FieldLabel(text), collapsedFieldName);
this.recomputeAriaLabel();
}
/**
@@ -1108,6 +1125,8 @@ export class BlockSvg
for (const child of this.getChildren(false)) {
child.updateDisabled();
}
this.recomputeAriaLabel();
}
/**
@@ -1752,8 +1771,6 @@ export class BlockSvg
* settings.
*/
render() {
this.recomputeAriaLabel();
this.queueRender();
renderManagement.triggerQueuedRenders();
}
@@ -1765,8 +1782,6 @@ export class BlockSvg
* @internal
*/
renderEfficiently() {
this.recomputeAriaLabel();
dom.startTextWidthCache();
if (this.isCollapsed()) {
@@ -1948,6 +1963,12 @@ export class BlockSvg
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
if (this.isSimpleReporter()) {
const field = Array.from(this.getFields())[0];
if (field && field.isFullBlockField() && field.isCurrentlyEditable()) {
return field.getFocusableElement();
}
}
return this.pathObject.svgPath;
}
@@ -1958,6 +1979,7 @@ export class BlockSvg
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.recomputeAriaLabel();
this.select();
this.workspace.scrollBoundsIntoView(
this.getBoundingRectangleWithoutChildren(),
@@ -2038,6 +2060,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary {
return block.inputList
.flatMap((input) => {
const fields = input.fieldRow.map((field) => {
if (!field.isVisible()) return [];
// 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) {
@@ -2046,6 +2069,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary {
return [field.getText() ?? field.getValue()];
});
if (
input.isVisible() &&
input.connection &&
input.connection.type === ConnectionType.INPUT_VALUE
) {

View File

@@ -196,9 +196,6 @@ export abstract class Field<T = any>
*/
SERIALIZABLE = false;
/** The unique ID of this field. */
private id_: string | null = null;
private config: FieldConfig | null = null;
/**
@@ -272,7 +269,6 @@ export abstract class Field<T = any>
`problems with focus: ${block.id}.`,
);
}
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
}
getAriaName(): string | null {
@@ -327,11 +323,8 @@ export abstract class Field<T = any>
// Field has already been initialized once.
return;
}
const id = this.id_;
if (!id) throw new Error('Expected ID to be defined prior to init.');
this.fieldGroup_ = dom.createSvgElement(Svg.G, {
'id': id,
});
this.fieldGroup_ = dom.createSvgElement(Svg.G, {});
if (!this.isVisible()) {
this.fieldGroup_.style.display = 'none';
}
@@ -343,6 +336,14 @@ export abstract class Field<T = any>
this.bindEvents_();
this.initModel();
this.applyColour();
const id =
this.isFullBlockField() &&
this.isCurrentlyEditable() &&
this.sourceBlock_?.isSimpleReporter()
? idGenerator.getNextUniqueId()
: `${this.sourceBlock_?.id}_field_${idGenerator.getNextUniqueId()}`;
this.fieldGroup_.setAttribute('id', id);
}
/**

View File

@@ -202,7 +202,7 @@ export class FieldDropdown extends Field<string> {
this.recomputeAria();
}
private recomputeAria() {
protected recomputeAria() {
if (!this.fieldGroup_) return; // There's no element to set currently.
const element = this.getFocusableElement();
aria.setRole(element, aria.Role.COMBOBOX);
@@ -213,17 +213,15 @@ export class FieldDropdown extends Field<string> {
} else {
aria.clearState(element, aria.State.CONTROLS);
}
aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Dropdown');
// Ensure the selected item has its correct label presented since it may be
// different than the actual text presented to the user.
if (this.textElement_) {
aria.setState(
this.textElement_,
aria.State.LABEL,
this.computeLabelForOption(this.selectedOption),
);
}
const label = [
this.computeLabelForOption(this.selectedOption),
this.getAriaName(),
]
.filter((item) => !!item)
.join(', ');
aria.setState(element, aria.State.LABEL, label);
}
/**
@@ -645,7 +643,6 @@ export class FieldDropdown extends Field<string> {
const element = this.getFocusableElement();
aria.setState(element, aria.State.ACTIVEDESCENDANT, textElement.id);
}
aria.setState(textElement, aria.State.HIDDEN, false);
// Height and width include the border rect.
const hasBorder = !!this.borderRect_;

View File

@@ -163,11 +163,10 @@ export class FieldImage extends Field<string> {
aria.setRole(element, aria.Role.IMAGE);
}
aria.setState(
element,
aria.State.LABEL,
this.altText ?? this.getAriaName(),
);
const label = [this.altText, this.getAriaName()]
.filter((item) => !!item)
.join(', ');
aria.setState(element, aria.State.LABEL, label);
}
override updateSize_() {}

View File

@@ -178,13 +178,23 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
dom.addClass(this.fieldGroup_, 'blocklyInputField');
}
// Showing the text-based value with the input's textbox ensures that the
// input's value is correctly read out by screen readers with its role.
aria.setState(this.textElement_, aria.State.HIDDEN, false);
const element = this.getFocusableElement();
aria.setRole(element, aria.Role.BUTTON);
this.recomputeAriaLabel();
}
/**
* Updates the ARIA label for this field.
*/
protected recomputeAriaLabel() {
if (!this.fieldGroup_) return;
const element = this.getFocusableElement();
aria.setRole(element, aria.Role.TEXTBOX);
aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Text');
const label = [this.getValue(), this.getAriaName()]
.filter((item) => !!item)
.join(', ');
aria.setState(element, aria.State.LABEL, label);
}
override isFullBlockField(): boolean {
@@ -248,6 +258,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
this.isDirty_ = true;
this.isTextValid_ = true;
this.value_ = newValue;
this.recomputeAriaLabel();
}
/**

View File

@@ -193,6 +193,9 @@ export class Input {
child.getSvgRoot().style.display = visible ? 'block' : 'none';
}
}
if (this.sourceBlock.rendered) {
(this.sourceBlock as BlockSvg).recomputeAriaLabel();
}
return renderList;
}
@@ -312,10 +315,20 @@ export class Input {
* @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()}`;
}, '');
getFieldRowLabel(): string {
const fieldRowLabel = this.fieldRow
.reduce((label, field) => {
return `${label} ${field.getValue()}`;
}, '')
.trim();
if (!fieldRowLabel) {
const inputs = this.getSourceBlock().inputList;
const index = inputs.indexOf(this);
if (index > 0) {
return inputs[index - 1].getFieldRowLabel();
}
}
return fieldRowLabel;
}
/**

View File

@@ -75,10 +75,12 @@ export class MenuItem {
const content = document.createElement('div');
content.className = 'blocklyMenuItemContent';
aria.setRole(content, aria.Role.PRESENTATION);
// Add a checkbox for checkable menu items.
if (this.checkable) {
const checkbox = document.createElement('div');
checkbox.className = 'blocklyMenuItemCheckbox ';
aria.setRole(checkbox, aria.Role.PRESENTATION);
content.appendChild(checkbox);
}