Skip to content

Commit 91d02ee

Browse files
authored
feat: Add basic support for generating ARIA labels and roles for blocks (#9696)
* feat: Add basic support for generating ARIA labels and roles for blocks * test: Add tests * chore: Fix lint * chore: Revert tooling removal of authors * chore: Adjust casing of method name * chore: Tweak name of verbosity enum value * chore: Adjust name of shadow block label method * chore: Add trailing newline * chore: Fix method casing * feat: Add method to retrieve a block's ARIA label * fix: Fix TSDoc * chore: Adjust method casing
1 parent dc2afe3 commit 91d02ee

7 files changed

Lines changed: 612 additions & 5 deletions

File tree

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Raspberry Pi Foundation
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {BlockSvg} from './block_svg.js';
8+
import {ConnectionType} from './connection_type.js';
9+
import type {Input} from './inputs/input.js';
10+
import {inputTypes} from './inputs/input_types.js';
11+
import {
12+
ISelectableToolboxItem,
13+
isSelectableToolboxItem,
14+
} from './interfaces/i_selectable_toolbox_item.js';
15+
import {Msg} from './msg.js';
16+
import {Role, setRole, setState, State, Verbosity} from './utils/aria.js';
17+
18+
/**
19+
* Returns an ARIA representation of the specified block.
20+
*
21+
* The returned label will contain a complete context of the block, including:
22+
* - Whether it begins a block stack or statement input stack.
23+
* - Its constituent editable and non-editable fields.
24+
* - Properties, including: disabled, collapsed, replaceable (a shadow), etc.
25+
* - Its parent toolbox category.
26+
* - Whether it has inputs.
27+
*
28+
* Beyond this, the returned label is specifically assembled with commas in
29+
* select locations with the intention of better 'prosody' in the screen reader
30+
* readouts since there's a lot of information being shared with the user. The
31+
* returned label also places more important information earlier in the label so
32+
* that the user gets the most important context as soon as possible in case
33+
* they wish to stop readout early.
34+
*
35+
* The returned label will be specialized based on whether the block is part of a
36+
* flyout.
37+
*
38+
* @internal
39+
* @param block The block for which an ARIA representation should be created.
40+
* @param verbosity How much detail to include in the description.
41+
* @returns The ARIA representation for the specified block.
42+
*/
43+
export function computeAriaLabel(
44+
block: BlockSvg,
45+
verbosity = Verbosity.STANDARD,
46+
) {
47+
return [
48+
getBeginStackLabel(block),
49+
getParentInputLabel(block),
50+
...getInputLabels(block),
51+
verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block),
52+
verbosity >= Verbosity.STANDARD && getDisabledLabel(block),
53+
verbosity >= Verbosity.STANDARD && getCollapsedLabel(block),
54+
verbosity >= Verbosity.STANDARD && getShadowBlockLabel(block),
55+
verbosity >= Verbosity.STANDARD && getInputCountLabel(block),
56+
]
57+
.filter((label) => !!label)
58+
.join(', ');
59+
}
60+
61+
/**
62+
* Sets the ARIA role and role description for the specified block, accounting
63+
* for whether the block is part of a flyout.
64+
*
65+
* @internal
66+
* @param block The block to set ARIA role and roledescription attributes on.
67+
*/
68+
export function configureAriaRole(block: BlockSvg) {
69+
setRole(block.getSvgRoot(), block.isInFlyout ? Role.LISTITEM : Role.FIGURE);
70+
71+
let roleDescription = Msg['BLOCK_LABEL_STATEMENT'];
72+
if (block.statementInputCount) {
73+
roleDescription = Msg['BLOCK_LABEL_CONTAINER'];
74+
} else if (block.outputConnection) {
75+
roleDescription = Msg['BLOCK_LABEL_VALUE'];
76+
}
77+
78+
setState(block.getSvgRoot(), State.ROLEDESCRIPTION, roleDescription);
79+
}
80+
81+
/**
82+
* Returns a list of ARIA labels for the 'field row' for the specified Input.
83+
*
84+
* 'Field row' essentially means the horizontal run of readable fields that
85+
* precede the Input. Together, these provide the domain context for the input,
86+
* particularly in the context of connections. In some cases, there may not be
87+
* any readable fields immediately prior to the Input. In that case, if the
88+
* `lookback` attribute is specified, all of the fields on the row immediately
89+
* above the Input will be used instead.
90+
*
91+
* @internal
92+
* @param input The Input to compute a description/context label for.
93+
* @param lookback If true, will use labels for fields on the previous row if
94+
* the given input's row has no fields itself.
95+
* @returns A list of labels for fields on the same row (or previous row, if
96+
* lookback is specified) as the given input.
97+
*/
98+
export function computeFieldRowLabel(
99+
input: Input,
100+
lookback: boolean,
101+
): string[] {
102+
const fieldRowLabel = input.fieldRow
103+
.filter((field) => field.isVisible())
104+
.map((field) => field.computeAriaLabel(true));
105+
if (!fieldRowLabel.length && lookback) {
106+
const inputs = input.getSourceBlock().inputList;
107+
const index = inputs.indexOf(input);
108+
if (index > 0) {
109+
return computeFieldRowLabel(inputs[index - 1], lookback);
110+
}
111+
}
112+
return fieldRowLabel;
113+
}
114+
115+
/**
116+
* Returns a description of the parent statement input a block is attached to.
117+
* When a block is connected to a statement input, the input's field row label
118+
* will be prepended to the block's description to indicate that the block
119+
* begins a clause in its parent block.
120+
*
121+
* @internal
122+
* @param block The block to generate a parent input label for.
123+
* @returns A description of the block's parent statement input, or undefined
124+
* for blocks that do not have one.
125+
*/
126+
function getParentInputLabel(block: BlockSvg) {
127+
const parentInput = (
128+
block.outputConnection ?? block.previousConnection
129+
)?.targetConnection?.getParentInput();
130+
const parentBlock = parentInput?.getSourceBlock();
131+
132+
if (!parentBlock?.statementInputCount) return undefined;
133+
134+
const firstStatementInput = parentBlock.inputList.find(
135+
(i) => i.type === inputTypes.STATEMENT,
136+
);
137+
// The first statement input in a block has no field row label as it would
138+
// be duplicative of the block's label.
139+
if (!parentInput || parentInput === firstStatementInput) {
140+
return undefined;
141+
}
142+
143+
const parentInputLabel = computeFieldRowLabel(parentInput, true);
144+
return parentInput.type === inputTypes.STATEMENT
145+
? Msg['BLOCK_LABEL_BEGIN_PREFIX'].replace('%1', parentInputLabel.join(' '))
146+
: parentInputLabel;
147+
}
148+
149+
/**
150+
* Returns text indicating that a block is the root block of a stack.
151+
*
152+
* @internal
153+
* @param block The block to retrieve a label for.
154+
* @returns Text indicating that the block begins a stack, or undefined if it
155+
* does not.
156+
*/
157+
function getBeginStackLabel(block: BlockSvg) {
158+
return !block.workspace.isFlyout && block.getRootBlock() === block
159+
? Msg['BLOCK_LABEL_BEGIN_STACK']
160+
: undefined;
161+
}
162+
163+
/**
164+
* Returns a list of accessibility labels for fields and inputs on a block.
165+
* Each entry in the returned array corresponds to one of: (a) a label for a
166+
* continuous run of non-interactable fields, (b) a label for an editable field,
167+
* (c) a label for an input. When an input contains nested blocks/fields/inputs,
168+
* their contents are returned as a single item in the array per top-level
169+
* input.
170+
*
171+
* @internal
172+
* @param block The block to retrieve a list of field/input labels for.
173+
* @returns A list of field/input labels for the given block.
174+
*/
175+
function getInputLabels(block: BlockSvg): string[] {
176+
return block.inputList
177+
.filter((input) => input.isVisible())
178+
.flatMap((input) => {
179+
const labels = computeFieldRowLabel(input, false);
180+
181+
if (input.connection?.type === ConnectionType.INPUT_VALUE) {
182+
const childBlock = input.connection.targetBlock();
183+
if (childBlock) {
184+
labels.push(getInputLabels(childBlock as BlockSvg).join(' '));
185+
}
186+
}
187+
188+
return labels;
189+
});
190+
}
191+
192+
/**
193+
* Returns the name of the toolbox category that the given block is part of.
194+
* This is heuristic-based; each toolbox category's contents are enumerated, and
195+
* if a block with the given block's type is encountered, that category is
196+
* deemed to be its parent. As a fallback, a toolbox category with the same
197+
* colour as the block may be returned. This is not comprehensive; blocks may
198+
* exist on the workspace which are not part of any category, or a given block
199+
* type may be part of multiple categories or belong to a dynamically-generated
200+
* category, or there may not even be a toolbox at all. In these cases, either
201+
* the first matching category or undefined will be returned.
202+
*
203+
* This method exists to attempt to provide similar context as block colour
204+
* provides to sighted users, e.g. where a red block comes from a red category.
205+
* It is inherently best-effort due to the above-mentioned constraints.
206+
*
207+
* @internal
208+
* @param block The block to retrieve a category name for.
209+
* @returns A description of the given block's parent toolbox category if any,
210+
* otherwise undefined.
211+
*/
212+
function getParentToolboxCategoryLabel(block: BlockSvg) {
213+
const toolbox = block.workspace.getToolbox();
214+
if (!toolbox) return undefined;
215+
216+
let parentCategory: ISelectableToolboxItem | undefined = undefined;
217+
for (const category of toolbox.getToolboxItems()) {
218+
if (!isSelectableToolboxItem(category)) continue;
219+
220+
const contents = category.getContents();
221+
if (
222+
Array.isArray(contents) &&
223+
contents.some(
224+
(item) =>
225+
item.kind.toLowerCase() === 'block' &&
226+
'type' in item &&
227+
item.type === block.type,
228+
)
229+
) {
230+
parentCategory = category;
231+
break;
232+
}
233+
234+
if (
235+
'getColour' in category &&
236+
typeof category.getColour === 'function' &&
237+
category.getColour() === block.getColour()
238+
) {
239+
parentCategory = category;
240+
}
241+
}
242+
243+
if (parentCategory) {
244+
return Msg['BLOCK_LABEL_TOOLBOX_CATEGORY'].replace(
245+
'%1',
246+
parentCategory.getName(),
247+
);
248+
}
249+
250+
return undefined;
251+
}
252+
253+
/**
254+
* Returns a label indicating that the block is disabled.
255+
*
256+
* @internal
257+
* @param block The block to generate a label for.
258+
* @returns A label indicating that the block is disabled (if it is), otherwise
259+
* undefined.
260+
*/
261+
export function getDisabledLabel(block: BlockSvg) {
262+
return block.isEnabled() ? undefined : Msg['BLOCK_LABEL_DISABLED'];
263+
}
264+
265+
/**
266+
* Returns a label indicating that the block is collapsed.
267+
*
268+
* @internal
269+
* @param block The block to generate a label for.
270+
* @returns A label indicating that the block is collapsed (if it is), otherwise
271+
* undefined.
272+
*/
273+
function getCollapsedLabel(block: BlockSvg) {
274+
return block.isCollapsed() ? Msg['BLOCK_LABEL_COLLAPSED'] : undefined;
275+
}
276+
277+
/**
278+
* Returns a label indicating that the block is a shadow block.
279+
*
280+
* @internal
281+
* @param block The block to generate a label for.
282+
* @returns A label indicating that the block is a shadow (if it is), otherwise
283+
* undefined.
284+
*/
285+
function getShadowBlockLabel(block: BlockSvg) {
286+
return block.isShadow() ? Msg['BLOCK_LABEL_REPLACEABLE'] : undefined;
287+
}
288+
289+
/**
290+
* Returns a label indicating whether the block has one or multiple inputs.
291+
*
292+
* @internal
293+
* @param block The block to generate a label for.
294+
* @returns A label indicating that the block has one or multiple inputs,
295+
* otherwise undefined.
296+
*/
297+
function getInputCountLabel(block: BlockSvg) {
298+
const inputCount = block.inputList.reduce((totalSum, input) => {
299+
return (
300+
input.fieldRow.reduce((fieldCount, field) => {
301+
return field.EDITABLE && !field.isFullBlockField()
302+
? fieldCount++
303+
: fieldCount;
304+
}, totalSum) +
305+
(input.connection?.type === ConnectionType.INPUT_VALUE ? 1 : 0)
306+
);
307+
}, 0);
308+
309+
switch (inputCount) {
310+
case 0:
311+
return undefined;
312+
case 1:
313+
return Msg['BLOCK_LABEL_HAS_INPUT'];
314+
default:
315+
return Msg['BLOCK_LABEL_HAS_INPUTS'];
316+
}
317+
}

packages/blockly/core/block_svg.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import './events/events_selected.js';
1616

1717
import {Block} from './block.js';
1818
import * as blockAnimations from './block_animations.js';
19+
import {computeAriaLabel, configureAriaRole} from './block_aria_composer.js';
1920
import * as browserEvents from './browser_events.js';
2021
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
2122
import * as common from './common.js';
@@ -62,6 +63,7 @@ import * as blocks from './serialization/blocks.js';
6263
import type {BlockStyle} from './theme.js';
6364
import * as Tooltip from './tooltip.js';
6465
import {idGenerator} from './utils.js';
66+
import * as aria from './utils/aria.js';
6567
import {Coordinate} from './utils/coordinate.js';
6668
import * as dom from './utils/dom.js';
6769
import {Rect} from './utils/rect.js';
@@ -244,6 +246,7 @@ export class BlockSvg
244246
if (!svg.parentNode) {
245247
this.workspace.getCanvas().appendChild(svg);
246248
}
249+
this.recomputeAriaAttributes();
247250
this.initialized = true;
248251
}
249252

@@ -606,6 +609,7 @@ export class BlockSvg
606609
this.getInput(collapsedInputName) ||
607610
this.appendDummyInput(collapsedInputName);
608611
input.appendField(new FieldLabel(text), collapsedFieldName);
612+
this.recomputeAriaAttributes();
609613
}
610614

611615
/**
@@ -842,6 +846,7 @@ export class BlockSvg
842846
override setShadow(shadow: boolean) {
843847
super.setShadow(shadow);
844848
this.applyColour();
849+
this.recomputeAriaAttributes();
845850
}
846851

847852
/**
@@ -1062,6 +1067,7 @@ export class BlockSvg
10621067
for (const child of this.getChildren(false)) {
10631068
child.updateDisabled();
10641069
}
1070+
this.recomputeAriaAttributes();
10651071
}
10661072

10671073
/**
@@ -1885,6 +1891,7 @@ export class BlockSvg
18851891

18861892
/** See IFocusableNode.onNodeFocus. */
18871893
onNodeFocus(): void {
1894+
this.recomputeAriaAttributes();
18881895
this.select();
18891896
if (getFocusManager().getFocusedNode() !== this) {
18901897
renderManagement.finishQueuedRenders().then(() => {
@@ -1986,4 +1993,23 @@ export class BlockSvg
19861993
// All other blocks are their own row.
19871994
return this.id;
19881995
}
1996+
1997+
/**
1998+
* Updates the ARIA label, role and roledescription for this block.
1999+
*/
2000+
private recomputeAriaAttributes() {
2001+
aria.setState(this.getSvgRoot(), aria.State.LABEL, computeAriaLabel(this));
2002+
configureAriaRole(this);
2003+
}
2004+
2005+
/**
2006+
* Returns a description of this block suitable for screenreaders or use in
2007+
* ARIA attributes.
2008+
*
2009+
* @param verbosity How much detail to include in the description.
2010+
* @returns An accessibility description of this block.
2011+
*/
2012+
getAriaLabel(verbosity: aria.Verbosity) {
2013+
return computeAriaLabel(this, verbosity);
2014+
}
19892015
}

0 commit comments

Comments
 (0)