Skip to content

Commit 4f475c7

Browse files
authored
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.
1 parent c8a7fc6 commit 4f475c7

File tree

7 files changed

+105
-58
lines changed

7 files changed

+105
-58
lines changed

core/block_svg.ts

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,17 @@ export class BlockSvg
226226
this.computeAriaRole();
227227
}
228228

229-
private recomputeAriaLabel() {
229+
/**
230+
* Updates the ARIA label of this block to reflect its current configuration.
231+
*
232+
* @internal
233+
*/
234+
recomputeAriaLabel() {
235+
if (this.isSimpleReporter()) {
236+
const field = Array.from(this.getFields())[0];
237+
if (field.isFullBlockField() && field.isCurrentlyEditable()) return;
238+
}
239+
230240
aria.setState(
231241
this.getFocusableElement(),
232242
aria.State.LABEL,
@@ -240,34 +250,42 @@ export class BlockSvg
240250
? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}`
241251
: '';
242252

243-
let currentBlock: Block | null = null;
253+
let currentBlock: BlockSvg | null = null;
244254
let nestedStatementBlockCount = 0;
245-
// This won't work well for if/else blocks.
246-
this.inputList.forEach((input) => {
255+
256+
for (const input of this.inputList) {
247257
if (
248258
input.connection &&
249259
input.connection.type === ConnectionType.NEXT_STATEMENT
250260
) {
251-
currentBlock = input.connection.targetBlock();
261+
currentBlock = input.connection.targetBlock() as BlockSvg | null;
262+
while (currentBlock) {
263+
nestedStatementBlockCount++;
264+
currentBlock = currentBlock.getNextBlock();
265+
}
252266
}
253-
});
254-
// The type is poorly inferred here.
255-
while (currentBlock as Block | null) {
256-
nestedStatementBlockCount++;
257-
// The type is poorly inferred here.
258-
// If currentBlock is null, we can't enter this while loop...
259-
currentBlock = currentBlock!.getNextBlock();
260267
}
261268

262269
let blockTypeText = 'block';
263270
if (this.isShadow()) {
264-
blockTypeText = 'input block';
265-
} else if (this.outputConnection) {
266271
blockTypeText = 'replacable block';
272+
} else if (this.outputConnection) {
273+
blockTypeText = 'input block';
267274
} else if (this.statementInputCount) {
268275
blockTypeText = 'C-shaped block';
269276
}
270277

278+
const modifiers = [];
279+
if (!this.isEnabled()) {
280+
modifiers.push('disabled');
281+
}
282+
if (this.isCollapsed()) {
283+
modifiers.push('collapsed');
284+
}
285+
if (modifiers.length) {
286+
blockTypeText = `${modifiers.join(' ')} ${blockTypeText}`;
287+
}
288+
271289
let prefix = '';
272290
const parentInput = (
273291
this.previousConnection ?? this.outputConnection
@@ -298,9 +316,7 @@ export class BlockSvg
298316
}
299317

300318
private computeAriaRole() {
301-
if (this.isSimpleReporter()) {
302-
aria.setRole(this.pathObject.svgPath, aria.Role.BUTTON);
303-
} else if (this.workspace.isFlyout) {
319+
if (this.workspace.isFlyout) {
304320
aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM);
305321
} else {
306322
aria.setState(
@@ -335,8 +351,6 @@ export class BlockSvg
335351
if (!svg.parentNode) {
336352
this.workspace.getCanvas().appendChild(svg);
337353
}
338-
// Note: This must be done after initialization of the block's fields.
339-
this.recomputeAriaLabel();
340354
this.initialized = true;
341355
}
342356

@@ -672,6 +686,7 @@ export class BlockSvg
672686
this.removeInput(collapsedInputName);
673687
dom.removeClass(this.svgGroup, 'blocklyCollapsed');
674688
this.setWarningText(null, BlockSvg.COLLAPSED_WARNING_ID);
689+
this.recomputeAriaLabel();
675690
return;
676691
}
677692

@@ -693,6 +708,8 @@ export class BlockSvg
693708
this.getInput(collapsedInputName) ||
694709
this.appendDummyInput(collapsedInputName);
695710
input.appendField(new FieldLabel(text), collapsedFieldName);
711+
712+
this.recomputeAriaLabel();
696713
}
697714

698715
/**
@@ -1108,6 +1125,8 @@ export class BlockSvg
11081125
for (const child of this.getChildren(false)) {
11091126
child.updateDisabled();
11101127
}
1128+
1129+
this.recomputeAriaLabel();
11111130
}
11121131

11131132
/**
@@ -1752,8 +1771,6 @@ export class BlockSvg
17521771
* settings.
17531772
*/
17541773
render() {
1755-
this.recomputeAriaLabel();
1756-
17571774
this.queueRender();
17581775
renderManagement.triggerQueuedRenders();
17591776
}
@@ -1765,8 +1782,6 @@ export class BlockSvg
17651782
* @internal
17661783
*/
17671784
renderEfficiently() {
1768-
this.recomputeAriaLabel();
1769-
17701785
dom.startTextWidthCache();
17711786

17721787
if (this.isCollapsed()) {
@@ -1948,6 +1963,12 @@ export class BlockSvg
19481963

19491964
/** See IFocusableNode.getFocusableElement. */
19501965
getFocusableElement(): HTMLElement | SVGElement {
1966+
if (this.isSimpleReporter()) {
1967+
const field = Array.from(this.getFields())[0];
1968+
if (field && field.isFullBlockField() && field.isCurrentlyEditable()) {
1969+
return field.getFocusableElement();
1970+
}
1971+
}
19511972
return this.pathObject.svgPath;
19521973
}
19531974

@@ -1958,6 +1979,7 @@ export class BlockSvg
19581979

19591980
/** See IFocusableNode.onNodeFocus. */
19601981
onNodeFocus(): void {
1982+
this.recomputeAriaLabel();
19611983
this.select();
19621984
this.workspace.scrollBoundsIntoView(
19631985
this.getBoundingRectangleWithoutChildren(),
@@ -2038,6 +2060,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary {
20382060
return block.inputList
20392061
.flatMap((input) => {
20402062
const fields = input.fieldRow.map((field) => {
2063+
if (!field.isVisible()) return [];
20412064
// If the block is a full block field, we only want to know if it's an
20422065
// editable field if we're not directly on it.
20432066
if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) {
@@ -2046,6 +2069,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary {
20462069
return [field.getText() ?? field.getValue()];
20472070
});
20482071
if (
2072+
input.isVisible() &&
20492073
input.connection &&
20502074
input.connection.type === ConnectionType.INPUT_VALUE
20512075
) {

core/field.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,6 @@ export abstract class Field<T = any>
196196
*/
197197
SERIALIZABLE = false;
198198

199-
/** The unique ID of this field. */
200-
private id_: string | null = null;
201-
202199
private config: FieldConfig | null = null;
203200

204201
/**
@@ -272,7 +269,6 @@ export abstract class Field<T = any>
272269
`problems with focus: ${block.id}.`,
273270
);
274271
}
275-
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
276272
}
277273

278274
getAriaName(): string | null {
@@ -327,11 +323,8 @@ export abstract class Field<T = any>
327323
// Field has already been initialized once.
328324
return;
329325
}
330-
const id = this.id_;
331-
if (!id) throw new Error('Expected ID to be defined prior to init.');
332-
this.fieldGroup_ = dom.createSvgElement(Svg.G, {
333-
'id': id,
334-
});
326+
327+
this.fieldGroup_ = dom.createSvgElement(Svg.G, {});
335328
if (!this.isVisible()) {
336329
this.fieldGroup_.style.display = 'none';
337330
}
@@ -343,6 +336,14 @@ export abstract class Field<T = any>
343336
this.bindEvents_();
344337
this.initModel();
345338
this.applyColour();
339+
340+
const id =
341+
this.isFullBlockField() &&
342+
this.isCurrentlyEditable() &&
343+
this.sourceBlock_?.isSimpleReporter()
344+
? idGenerator.getNextUniqueId()
345+
: `${this.sourceBlock_?.id}_field_${idGenerator.getNextUniqueId()}`;
346+
this.fieldGroup_.setAttribute('id', id);
346347
}
347348

348349
/**

core/field_dropdown.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ export class FieldDropdown extends Field<string> {
202202
this.recomputeAria();
203203
}
204204

205-
private recomputeAria() {
205+
protected recomputeAria() {
206206
if (!this.fieldGroup_) return; // There's no element to set currently.
207207
const element = this.getFocusableElement();
208208
aria.setRole(element, aria.Role.COMBOBOX);
@@ -213,17 +213,15 @@ export class FieldDropdown extends Field<string> {
213213
} else {
214214
aria.clearState(element, aria.State.CONTROLS);
215215
}
216-
aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Dropdown');
217216

218-
// Ensure the selected item has its correct label presented since it may be
219-
// different than the actual text presented to the user.
220-
if (this.textElement_) {
221-
aria.setState(
222-
this.textElement_,
223-
aria.State.LABEL,
224-
this.computeLabelForOption(this.selectedOption),
225-
);
226-
}
217+
const label = [
218+
this.computeLabelForOption(this.selectedOption),
219+
this.getAriaName(),
220+
]
221+
.filter((item) => !!item)
222+
.join(', ');
223+
224+
aria.setState(element, aria.State.LABEL, label);
227225
}
228226

229227
/**
@@ -645,7 +643,6 @@ export class FieldDropdown extends Field<string> {
645643
const element = this.getFocusableElement();
646644
aria.setState(element, aria.State.ACTIVEDESCENDANT, textElement.id);
647645
}
648-
aria.setState(textElement, aria.State.HIDDEN, false);
649646

650647
// Height and width include the border rect.
651648
const hasBorder = !!this.borderRect_;

core/field_image.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,10 @@ export class FieldImage extends Field<string> {
163163
aria.setRole(element, aria.Role.IMAGE);
164164
}
165165

166-
aria.setState(
167-
element,
168-
aria.State.LABEL,
169-
this.altText ?? this.getAriaName(),
170-
);
166+
const label = [this.altText, this.getAriaName()]
167+
.filter((item) => !!item)
168+
.join(', ');
169+
aria.setState(element, aria.State.LABEL, label);
171170
}
172171

173172
override updateSize_() {}

core/field_input.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,23 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
178178
dom.addClass(this.fieldGroup_, 'blocklyInputField');
179179
}
180180

181-
// Showing the text-based value with the input's textbox ensures that the
182-
// input's value is correctly read out by screen readers with its role.
183-
aria.setState(this.textElement_, aria.State.HIDDEN, false);
181+
const element = this.getFocusableElement();
182+
aria.setRole(element, aria.Role.BUTTON);
183+
this.recomputeAriaLabel();
184+
}
185+
186+
/**
187+
* Updates the ARIA label for this field.
188+
*/
189+
protected recomputeAriaLabel() {
190+
if (!this.fieldGroup_) return;
184191

185192
const element = this.getFocusableElement();
186-
aria.setRole(element, aria.Role.TEXTBOX);
187-
aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Text');
193+
const label = [this.getValue(), this.getAriaName()]
194+
.filter((item) => !!item)
195+
.join(', ');
196+
197+
aria.setState(element, aria.State.LABEL, label);
188198
}
189199

190200
override isFullBlockField(): boolean {
@@ -248,6 +258,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
248258
this.isDirty_ = true;
249259
this.isTextValid_ = true;
250260
this.value_ = newValue;
261+
this.recomputeAriaLabel();
251262
}
252263

253264
/**

core/inputs/input.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ export class Input {
193193
child.getSvgRoot().style.display = visible ? 'block' : 'none';
194194
}
195195
}
196+
if (this.sourceBlock.rendered) {
197+
(this.sourceBlock as BlockSvg).recomputeAriaLabel();
198+
}
196199
return renderList;
197200
}
198201

@@ -312,10 +315,20 @@ export class Input {
312315
* @internal
313316
* @returns A description of this input's row on its parent block.
314317
*/
315-
getFieldRowLabel() {
316-
return this.fieldRow.reduce((label, field) => {
317-
return `${label} ${field.EDITABLE ? field.getAriaName() : field.getValue()}`;
318-
}, '');
318+
getFieldRowLabel(): string {
319+
const fieldRowLabel = this.fieldRow
320+
.reduce((label, field) => {
321+
return `${label} ${field.getValue()}`;
322+
}, '')
323+
.trim();
324+
if (!fieldRowLabel) {
325+
const inputs = this.getSourceBlock().inputList;
326+
const index = inputs.indexOf(this);
327+
if (index > 0) {
328+
return inputs[index - 1].getFieldRowLabel();
329+
}
330+
}
331+
return fieldRowLabel;
319332
}
320333

321334
/**

core/menuitem.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,12 @@ export class MenuItem {
7575

7676
const content = document.createElement('div');
7777
content.className = 'blocklyMenuItemContent';
78+
aria.setRole(content, aria.Role.PRESENTATION);
7879
// Add a checkbox for checkable menu items.
7980
if (this.checkable) {
8081
const checkbox = document.createElement('div');
8182
checkbox.className = 'blocklyMenuItemCheckbox ';
83+
aria.setRole(checkbox, aria.Role.PRESENTATION);
8284
content.appendChild(checkbox);
8385
}
8486

0 commit comments

Comments
 (0)