Skip to content

Commit 7454ffd

Browse files
committed
feat: Add basic support for generating ARIA labels and roles for blocks
1 parent c7fd3f3 commit 7454ffd

6 files changed

Lines changed: 414 additions & 16 deletions

File tree

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
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.NORMAL,
46+
) {
47+
return [
48+
getBeginStackLabel(block),
49+
getParentInputLabel(block),
50+
...getInputLabels(block),
51+
verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block),
52+
verbosity >= Verbosity.NORMAL && getDisabledLabel(block),
53+
verbosity >= Verbosity.NORMAL && getCollapsedLabel(block),
54+
verbosity >= Verbosity.NORMAL && getReplaceableLabel(block),
55+
verbosity >= Verbosity.NORMAL && 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 an ARIA representation of 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+
* Returns undefined if no fields precede the given Input.
92+
*
93+
* @internal
94+
* @param input The Input to compute a description/context label for.
95+
* @param lookback If true, will use fields on the previous row to compute a
96+
* label for the given input if it has no fields itself.
97+
* @returns An accessibility label for the given input, or undefined if one
98+
* cannot be computed.
99+
*/
100+
export function computeFieldRowLabel(
101+
input: Input,
102+
lookback: boolean,
103+
): string[] {
104+
const fieldRowLabel = input.fieldRow
105+
.filter((field) => field.isVisible())
106+
.map((field) => field.computeAriaLabel(true));
107+
if (!fieldRowLabel.length && lookback) {
108+
const inputs = input.getSourceBlock().inputList;
109+
const index = inputs.indexOf(input);
110+
if (index > 0) {
111+
return computeFieldRowLabel(inputs[index - 1], lookback);
112+
}
113+
}
114+
return fieldRowLabel;
115+
}
116+
117+
/**
118+
* Returns a description of the parent statement input a block is attached to.
119+
* When a block is connected to a statement input, the input's field row label
120+
* will be prepended to the block's description to indicate that the block
121+
* begins a clause in its parent block.
122+
*
123+
* @internal
124+
* @param block The block to generate a parent input label for.
125+
* @returns A description of the block's parent statement input, or undefined
126+
* for blocks that do not have one.
127+
*/
128+
function getParentInputLabel(block: BlockSvg) {
129+
const parentInput = (
130+
block.outputConnection ?? block.previousConnection
131+
)?.targetConnection?.getParentInput();
132+
const parentBlock = parentInput?.getSourceBlock();
133+
134+
if (!parentBlock?.statementInputCount) return undefined;
135+
136+
const firstStatementInput = parentBlock.inputList.find(
137+
(i) => i.type === inputTypes.STATEMENT,
138+
);
139+
// The first statement input in a block has no field row label as it would
140+
// be duplicative of the block's label.
141+
if (!parentInput || parentInput === firstStatementInput) {
142+
return undefined;
143+
}
144+
145+
const parentInputLabel = computeFieldRowLabel(parentInput, true);
146+
return parentInput.type === inputTypes.STATEMENT
147+
? Msg['BLOCK_LABEL_BEGIN_PREFIX'].replace('%1', parentInputLabel.join(' '))
148+
: parentInputLabel;
149+
}
150+
151+
/**
152+
* Returns text indicating that a block is the root block of a stack.
153+
*
154+
* @internal
155+
* @param block The block to retrieve a label for.
156+
* @returns Text indicating that the block begins a stack, or undefined if it
157+
* does not.
158+
*/
159+
function getBeginStackLabel(block: BlockSvg) {
160+
return !block.workspace.isFlyout && block.getRootBlock() === block
161+
? Msg['BLOCK_LABEL_BEGIN_STACK']
162+
: undefined;
163+
}
164+
165+
/**
166+
* Returns a list of accessibility labels for fields and inputs on a block.
167+
* Each entry in the returned array corresponds to one of: (a) a label for a
168+
* continuous run of non-interactable fields, (b) a label for an editable field,
169+
* (c) a label for an input. When an input contains nested blocks/fields/inputs,
170+
* their contents are returned as a single item in the array per top-level
171+
* input.
172+
*
173+
* @internal
174+
* @param block The block to retrieve a list of field/input labels for.
175+
* @returns A list of field/input labels for the given block.
176+
*/
177+
function getInputLabels(block: BlockSvg): string[] {
178+
return block.inputList
179+
.filter((input) => input.isVisible())
180+
.flatMap((input) => {
181+
const labels = computeFieldRowLabel(input, false);
182+
183+
if (input.connection?.type === ConnectionType.INPUT_VALUE) {
184+
const childBlock = input.connection.targetBlock();
185+
if (childBlock) {
186+
labels.push(getInputLabels(childBlock as BlockSvg).join(' '));
187+
}
188+
}
189+
190+
return labels;
191+
});
192+
}
193+
194+
/**
195+
* Returns the name of the toolbox category that the given block is part of.
196+
* This is heuristic-based; each toolbox category's contents are enumerated, and
197+
* if a block with the given block's type is encountered, that category is
198+
* deemed to be its parent. As a fallback, a toolbox category with the same
199+
* colour as the block may be returned. This is not comprehensive; blocks may
200+
* exist on the workspace which are not part of any category, or a given block
201+
* type may be part of multiple categories or belong to a dynamically-generated
202+
* category, or there may not even be a toolbox at all. In these cases, either
203+
* the first matching category or undefined will be returned.
204+
*
205+
* This method exists to attempt to provide similar context as block colour
206+
* provides to sighted users, e.g. where a red block comes from a red category.
207+
* It is inherently best-effort due to the above-mentioned constraints.
208+
*
209+
* @internal
210+
* @param block The block to retrieve a category name for.
211+
* @returns A description of the given block's parent toolbox category if any,
212+
* otherwise undefined.
213+
*/
214+
function getParentToolboxCategoryLabel(block: BlockSvg) {
215+
const toolbox = block.workspace.getToolbox();
216+
if (!toolbox) return undefined;
217+
218+
let parentCategory: ISelectableToolboxItem | undefined = undefined;
219+
for (const category of toolbox.getToolboxItems()) {
220+
if (!isSelectableToolboxItem(category)) continue;
221+
222+
const contents = category.getContents();
223+
if (
224+
Array.isArray(contents) &&
225+
contents.some(
226+
(item) =>
227+
item.kind.toLowerCase() === 'block' &&
228+
'type' in item &&
229+
item.type === block.type,
230+
)
231+
) {
232+
parentCategory = category;
233+
break;
234+
}
235+
236+
if (
237+
'getColour' in category &&
238+
typeof category.getColour === 'function' &&
239+
category.getColour() === block.getColour()
240+
) {
241+
parentCategory = category;
242+
}
243+
}
244+
245+
if (parentCategory) {
246+
return Msg['BLOCK_LABEL_TOOLBOX_CATEGORY'].replace(
247+
'%1',
248+
parentCategory.getName(),
249+
);
250+
}
251+
252+
return undefined;
253+
}
254+
255+
/**
256+
* Returns a label indicating that the block is disabled.
257+
*
258+
* @internal
259+
* @param block The block to generate a label for.
260+
* @returns A label indicating that the block is disabled (if it is), otherwise
261+
* undefined.
262+
*/
263+
export function getDisabledLabel(block: BlockSvg) {
264+
return block.isEnabled() ? undefined : Msg['BLOCK_LABEL_DISABLED'];
265+
}
266+
267+
/**
268+
* Returns a label indicating that the block is collapsed.
269+
*
270+
* @internal
271+
* @param block The block to generate a label for.
272+
* @returns A label indicating that the block is collapsed (if it is), otherwise
273+
* undefined.
274+
*/
275+
function getCollapsedLabel(block: BlockSvg) {
276+
return block.isCollapsed() ? Msg['BLOCK_LABEL_COLLAPSED'] : undefined;
277+
}
278+
279+
/**
280+
* Returns a label indicating that the block is a shadow block.
281+
*
282+
* @internal
283+
* @param block The block to generate a label for.
284+
* @returns A label indicating that the block is a shadow (if it is), otherwise
285+
* undefined.
286+
*/
287+
function getReplaceableLabel(block: BlockSvg) {
288+
return block.isShadow() ? Msg['BLOCK_LABEL_REPLACEABLE'] : undefined;
289+
}
290+
291+
/**
292+
* Returns a label indicating whether the block has one or multiple inputs.
293+
*
294+
* @internal
295+
* @param block The block to generate a label for.
296+
* @returns A label indicating that the block has one or multiple inputs,
297+
* otherwise undefined.
298+
*/
299+
function getInputCountLabel(block: BlockSvg) {
300+
const inputCount = block.inputList.reduce((totalSum, input) => {
301+
return (
302+
input.fieldRow.reduce((fieldCount, field) => {
303+
return field.EDITABLE && !field.isFullBlockField()
304+
? fieldCount++
305+
: fieldCount;
306+
}, totalSum) +
307+
(input.connection?.type === ConnectionType.INPUT_VALUE ? 1 : 0)
308+
);
309+
}, 0);
310+
311+
switch (inputCount) {
312+
case 0:
313+
return undefined;
314+
case 1:
315+
return Msg['BLOCK_LABEL_HAS_INPUT'];
316+
default:
317+
return Msg['BLOCK_LABEL_HAS_INPUTS'];
318+
}
319+
}

packages/blockly/core/block_svg.ts

Lines changed: 15 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,12 @@ 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+
}
19892004
}

0 commit comments

Comments
 (0)