Skip to content

Commit 5a63e99

Browse files
authored
feat: add aria labels for connections (#9862)
* feat: add aria labels for connections * chore: add tests * chore: fix tests * chore: typo
1 parent c8e0700 commit 5a63e99

7 files changed

Lines changed: 204 additions & 3 deletions

File tree

packages/blockly/core/block_aria_composer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ export function getInputLabels(
284284
* @param input The input that defines the end of the subset.
285285
* @returns A list of field/input labels for the given block.
286286
*/
287-
function getInputLabelsSubset(block: BlockSvg, input: Input): string[] {
287+
export function getInputLabelsSubset(block: BlockSvg, input: Input): string[] {
288288
const inputIndex = block.inputList.indexOf(input);
289289
if (inputIndex === -1) {
290290
throw new Error(

packages/blockly/core/rendered_connection.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212
// Former goog.module ID: Blockly.RenderedConnection
1313

14+
import {getInputLabelsSubset} from './block_aria_composer.js';
1415
import type {BlockSvg} from './block_svg.js';
1516
import {config} from './config.js';
1617
import {Connection} from './connection.js';
@@ -24,6 +25,8 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js';
2425
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
2526
import {hasBubble} from './interfaces/i_has_bubble.js';
2627
import * as internalConstants from './internal_constants.js';
28+
import {Msg} from './msg.js';
29+
import * as aria from './utils/aria.js';
2730
import {Coordinate} from './utils/coordinate.js';
2831
import * as svgMath from './utils/svg_math.js';
2932
import {WorkspaceSvg} from './workspace_svg.js';
@@ -318,6 +321,57 @@ export class RenderedConnection
318321
return this.dbOpposite.searchForClosest(this, maxLimit, dxy);
319322
}
320323

324+
/**
325+
* Sets the aria role, label, and other state for this connection.
326+
*
327+
* @param highlightSvg The focusable element for this connection.
328+
*/
329+
private recomputeAriaContext(highlightSvg: SVGElement) {
330+
// Note that output connections don't have highlights so this doesn't need to take them into account.
331+
const roleDescription =
332+
this.type === ConnectionType.INPUT_VALUE
333+
? Msg['INPUT_LABEL_VALUE']
334+
: Msg['INPUT_LABEL_STATEMENT'];
335+
336+
aria.setRole(highlightSvg, aria.Role.FIGURE);
337+
aria.setState(highlightSvg, aria.State.ROLEDESCRIPTION, roleDescription);
338+
339+
// 'Next' connections are only focusable if they're the last connection
340+
// inside a statement input. The label for these connections comes from
341+
// that statement input, even though there may be a stack of blocks
342+
// between this current connection and that statement input.
343+
const parentInput =
344+
this.getParentInput() ??
345+
this.getSourceBlock()
346+
.getTopStackBlock()
347+
.previousConnection?.targetConnection?.getParentInput();
348+
if (!parentInput) {
349+
// This doesn't happen in the default navigation policy, but it could happen
350+
// if using a different policy that enables navigation to all statement
351+
// inputs, for example.
352+
aria.setState(highlightSvg, aria.State.LABEL, Msg['INPUT_LABEL_EMPTY']);
353+
return;
354+
}
355+
356+
// Use the custom label for an input if it exists, otherwise use the
357+
// "field row" approach to get the default label for the input.
358+
const parentInputLabel =
359+
parentInput?.getAriaLabelText() ??
360+
getInputLabelsSubset(
361+
parentInput.getSourceBlock() as BlockSvg,
362+
parentInput,
363+
).join(', ');
364+
if (this.type === ConnectionType.NEXT_STATEMENT) {
365+
aria.setState(
366+
highlightSvg,
367+
aria.State.LABEL,
368+
Msg['INPUT_LABEL_END_STATEMENT'].replace('%1', parentInputLabel),
369+
);
370+
} else {
371+
aria.setState(highlightSvg, aria.State.LABEL, parentInputLabel);
372+
}
373+
}
374+
321375
/** Add highlighting around this connection. */
322376
highlight() {
323377
this.highlighted = true;
@@ -332,6 +386,7 @@ export class RenderedConnection
332386
if (highlightSvg) {
333387
highlightSvg.style.display = '';
334388
highlightSvg.parentElement?.appendChild(highlightSvg);
389+
this.recomputeAriaContext(highlightSvg);
335390
}
336391
}
337392

@@ -656,7 +711,9 @@ export class RenderedConnection
656711

657712
/** See IFocusableNode.canBeFocused. */
658713
canBeFocused(): boolean {
659-
return true;
714+
// Since the highlightSvg is the focusable element,
715+
// if it doesn't exist then the connection can't be focused.
716+
return this.findHighlightSvg() !== null;
660717
}
661718

662719
private findHighlightSvg(): SVGPathElement | null {

packages/blockly/msg/json/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"@metadata": {
33
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
4-
"lastupdated": "2026-05-13 09:26:53.422108",
4+
"lastupdated": "2026-05-12 16:03:06.800029",
55
"locale": "en",
66
"messagedocumentation" : "qqq"
77
},
@@ -480,6 +480,10 @@
480480
"BLOCK_LABEL_VALUE": "value",
481481
"BLOCK_LABEL_STACK_BLOCKS": "%1 stack blocks",
482482
"INPUT_LABEL_INDEX": "input %1",
483+
"INPUT_LABEL_VALUE": "value position",
484+
"INPUT_LABEL_STATEMENT": "command position",
485+
"INPUT_LABEL_END_STATEMENT": "End %1",
486+
"INPUT_LABEL_EMPTY": "Empty",
483487
"ANNOUNCE_MOVE_WORKSPACE": "Moving %1 on workspace.",
484488
"ANNOUNCE_MOVE_BEFORE": "Moving %1 before %2.",
485489
"ANNOUNCE_MOVE_AFTER": "Moving %1 after %2.",

packages/blockly/msg/json/qqq.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,10 @@
474474
"BLOCK_LABEL_VALUE": "Part of an accessibility label for a block that indicates that it is a value block, i.e. that it has an output connection.",
475475
"BLOCK_LABEL_STACK_BLOCKS": "Accessibility label for a block that indicates it is a stack of two or more blocks.",
476476
"INPUT_LABEL_INDEX": "Accessibility label for an unlabeled input that communicates its index on the block. \n\nParameters:\n* %1 - the index of the input, starting at 1",
477+
"INPUT_LABEL_VALUE": "Accessibility label for an empty connection that can hold a value block. This should use the same language as the BLOCK_LABEL_VALUE string.",
478+
"INPUT_LABEL_STATEMENT": "Accessibility label for an empty next connection that can hold a statement block. This should use the same language as the BLOCK_LABEL_STATEMENT string.",
479+
"INPUT_LABEL_END_STATEMENT": "Accessibility label describing the last connection point inside a statement input. e.g. 'End if, true, do' where the 'if, true, do' is assembled from the statement input and calculated separately. \n\nParameters:\n* %1 - the label for the statement input that is ending.",
480+
"INPUT_LABEL_EMPTY": "Accessibility label describing an empty connection point that doesn't meet any other criteria for getting a more specific connection label.",
477481
"ANNOUNCE_MOVE_WORKSPACE": "ARIA live region message announcing a block is being moved on the workspace, without specifying a target location or specific movement direction.",
478482
"ANNOUNCE_MOVE_BEFORE": "ARIA live region message announcing a block is being moved before another block \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks\n* %2 - the label of the target (neighbour) block \n\nExamples:\n* 'Moving before repeat, 10, times, do.'\n* 'Moving print before repeat, 10, times, do.'",
479483
"ANNOUNCE_MOVE_AFTER": "ARIA live region message announcing a block is being moved after another block \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks\n* %2 - the label of the target (neighbour) block \n\nExamples:\n* 'Moving after repeat, 10, times, do.'\n* 'Moving 2 stack blocks after repeat, 10, times, do.'",

packages/blockly/msg/messages.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1903,6 +1903,24 @@ Blockly.Msg.BLOCK_LABEL_STACK_BLOCKS = '%1 stack blocks';
19031903
/// \n\nParameters:\n* %1 - the index of the input, starting at 1
19041904
Blockly.Msg.INPUT_LABEL_INDEX = 'input %1';
19051905
/** @type {string} */
1906+
/// Accessibility label for an empty connection that can hold a value block.
1907+
/// This should use the same language as the BLOCK_LABEL_VALUE string.
1908+
Blockly.Msg.INPUT_LABEL_VALUE = 'value position';
1909+
/** @type {string} */
1910+
/// Accessibility label for an empty next connection that can hold a statement
1911+
/// block. This should use the same language as the BLOCK_LABEL_STATEMENT string.
1912+
Blockly.Msg.INPUT_LABEL_STATEMENT = 'command position';
1913+
/** @type {string} */
1914+
/// Accessibility label describing the last connection point inside a statement
1915+
/// input. e.g. "End if, true, do" where the "if, true, do" is assembled from
1916+
/// the statement input and calculated separately.
1917+
/// \n\nParameters:\n* %1 - the label for the statement input that is ending.
1918+
Blockly.Msg.INPUT_LABEL_END_STATEMENT = 'End %1';
1919+
/** @type {string} */
1920+
/// Accessibility label describing an empty connection point that doesn't
1921+
/// meet any other criteria for getting a more specific connection label.
1922+
Blockly.Msg.INPUT_LABEL_EMPTY = 'Empty';
1923+
/** @type {string} */
19061924
/// ARIA live region message announcing a block is being moved on the workspace, without specifying a target location or specific movement direction.
19071925
// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks
19081926
// \n\nExamples:\n* "Moving if, do on workspace."\n* "Moving 2 stack blocks on workspace."

packages/blockly/tests/mocha/aria_test.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import {getInputLabelsSubset} from '../../build/src/core/block_aria_composer.js';
78
import {assert} from '../../node_modules/chai/index.js';
89
import {
910
sharedTestSetup,
@@ -521,4 +522,114 @@ suite('ARIA', function () {
521522
assert.isTrue(label.endsWith('has inputs'));
522523
});
523524
});
525+
526+
suite('Rendered connection highlight ARIA', function () {
527+
function assertHighlightAria(
528+
connection,
529+
expectedRoleDescription,
530+
labelSubstring,
531+
...moreLabelSubstrings
532+
) {
533+
const labelSubstrings = [labelSubstring, ...moreLabelSubstrings].flat();
534+
connection.highlight();
535+
try {
536+
const el = connection.getFocusableElement();
537+
assert.equal(
538+
Blockly.utils.aria.getRole(el),
539+
Blockly.utils.aria.Role.FIGURE,
540+
);
541+
assert.equal(
542+
Blockly.utils.aria.getState(
543+
el,
544+
Blockly.utils.aria.State.ROLEDESCRIPTION,
545+
),
546+
expectedRoleDescription,
547+
);
548+
const label = Blockly.utils.aria.getState(
549+
el,
550+
Blockly.utils.aria.State.LABEL,
551+
);
552+
for (const fragment of labelSubstrings) {
553+
assert.include(label, fragment);
554+
}
555+
} finally {
556+
connection.unhighlight();
557+
}
558+
}
559+
560+
setup(function () {
561+
this.renderBlock = (blockType) => {
562+
const block = this.workspace.newBlock(blockType);
563+
block.initSvg();
564+
block.render();
565+
return block;
566+
};
567+
});
568+
569+
test('value input connection uses value role description and computed label', function () {
570+
const negate = this.renderBlock('logic_negate');
571+
const boolInput = negate.getInput('BOOL');
572+
assertHighlightAria(
573+
boolInput.connection,
574+
Blockly.Msg.INPUT_LABEL_VALUE,
575+
'not',
576+
);
577+
});
578+
579+
test('empty statement input connection uses statement role description and end label', function () {
580+
const repeat = this.renderBlock('controls_repeat_ext');
581+
const doInput = repeat.getInput('DO');
582+
assertHighlightAria(
583+
doInput.connection,
584+
Blockly.Msg.INPUT_LABEL_STATEMENT,
585+
['End', ...getInputLabelsSubset(repeat, doInput)],
586+
);
587+
});
588+
589+
test('last next connection in a populated statement stack uses statement role description and end label', function () {
590+
const repeat = this.renderBlock('controls_repeat_ext');
591+
const printBlock = this.renderBlock('text_print');
592+
const doInput = repeat.getInput('DO');
593+
doInput.connection.connect(printBlock.previousConnection);
594+
595+
assertHighlightAria(
596+
printBlock.nextConnection,
597+
Blockly.Msg.INPUT_LABEL_STATEMENT,
598+
['End', ...getInputLabelsSubset(repeat, doInput)],
599+
);
600+
});
601+
602+
test('value input connection with custom input label uses custom label', function () {
603+
const negate = this.renderBlock('logic_negate');
604+
negate.getInput('BOOL').setAriaLabelProvider('custom value input');
605+
assertHighlightAria(
606+
negate.getInput('BOOL').connection,
607+
Blockly.Msg.INPUT_LABEL_VALUE,
608+
'custom value input',
609+
);
610+
});
611+
612+
test('empty statement input connection with custom input label uses end-of-statement label', function () {
613+
const repeat = this.renderBlock('controls_repeat_ext');
614+
repeat.getInput('DO').setAriaLabelProvider('custom repeat body');
615+
assertHighlightAria(
616+
repeat.getInput('DO').connection,
617+
Blockly.Msg.INPUT_LABEL_STATEMENT,
618+
['End', 'custom repeat body'],
619+
);
620+
});
621+
622+
test('last next connection in a populated statement stack respects custom statement input label', function () {
623+
const repeat = this.renderBlock('controls_repeat_ext');
624+
repeat.getInput('DO').setAriaLabelProvider('custom repeat body');
625+
const printBlock = this.renderBlock('text_print');
626+
repeat.getInput('DO').connection.connect(printBlock.previousConnection);
627+
628+
assertHighlightAria(
629+
printBlock.nextConnection,
630+
Blockly.Msg.INPUT_LABEL_STATEMENT,
631+
['End', 'custom repeat body'],
632+
);
633+
});
634+
});
524635
});

packages/blockly/tests/mocha/navigation_test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,13 @@ suite('Navigation', function () {
417417
this.blocks.buttonInput2 = buttonInput2;
418418
this.blocks.buttonNext = buttonNext;
419419

420+
// Blocks have to be rendered for their connections
421+
// to be focusable.
422+
this.workspace.getAllBlocks().forEach((block) => {
423+
block.initSvg();
424+
block.render();
425+
});
426+
420427
this.workspace.cleanUp();
421428
});
422429
suite('Next', function () {

0 commit comments

Comments
 (0)