From 91b768f351229e6a580bd62da91a6b22b92b5cc6 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 12 May 2026 16:13:13 -0400 Subject: [PATCH 1/4] feat: add aria labels for connections --- packages/blockly/core/block_aria_composer.ts | 2 +- packages/blockly/core/rendered_connection.ts | 59 +++++++++++++++++++- packages/blockly/msg/json/en.json | 6 +- packages/blockly/msg/json/qqq.json | 4 ++ packages/blockly/msg/messages.js | 18 ++++++ 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index d99e1b33b8c..69baa6b357d 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -253,7 +253,7 @@ export function getInputLabels( * @param input The input that defines the end of the subset. * @returns A list of field/input labels for the given block. */ -function getInputLabelsSubset(block: BlockSvg, input: Input): string[] { +export function getInputLabelsSubset(block: BlockSvg, input: Input): string[] { const inputIndex = block.inputList.indexOf(input); if (inputIndex === -1) { throw new Error( diff --git a/packages/blockly/core/rendered_connection.ts b/packages/blockly/core/rendered_connection.ts index d069cc87b3a..5a26704dfb4 100644 --- a/packages/blockly/core/rendered_connection.ts +++ b/packages/blockly/core/rendered_connection.ts @@ -11,6 +11,7 @@ */ // Former goog.module ID: Blockly.RenderedConnection +import {getInputLabelsSubset} from './block_aria_composer.js'; import type {BlockSvg} from './block_svg.js'; import {config} from './config.js'; import {Connection} from './connection.js'; @@ -24,6 +25,8 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {hasBubble} from './interfaces/i_has_bubble.js'; import * as internalConstants from './internal_constants.js'; +import {Msg} from './msg.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as svgMath from './utils/svg_math.js'; import {WorkspaceSvg} from './workspace_svg.js'; @@ -318,6 +321,57 @@ export class RenderedConnection return this.dbOpposite.searchForClosest(this, maxLimit, dxy); } + /** + * Sets the aria role, label, and other state for this connection. + * + * @param highlightSvg The focusable element for this connection. + */ + private recomputeAriaContext(highlightSvg: SVGElement) { + // Note that output connections don't have highlights so this doesn't need to take them into account. + const roleDescription = + this.type === ConnectionType.INPUT_VALUE + ? Msg['INPUT_LABEL_VALUE'] + : Msg['INPUT_LABEL_STATEMENT']; + + aria.setRole(highlightSvg, aria.Role.FIGURE); + aria.setState(highlightSvg, aria.State.ROLEDESCRIPTION, roleDescription); + + // 'Next' connections are only focusable if they're the last connection + // inside a statement input. The label for these connections comes from + // that statement input, even though there may be a stack of blocks + // between this current connection and that statement input. + const parentInput = + this.getParentInput() ?? + this.getSourceBlock() + .getTopStackBlock() + .previousConnection?.targetConnection?.getParentInput(); + if (!parentInput) { + // This doesn't happen in the default navigation policy, but it could happen + // if using a different policy that enables navigation to all statement + // inputs, for example. + aria.setState(highlightSvg, aria.State.LABEL, Msg['INPUT_LABEL_EMPTY']); + return; + } + + // Use the custom label for an input if it exists, otherwise use the + // "field row" approach to get the default label for the input. + const parentInputLabel = + parentInput?.getAriaLabelText() ?? + getInputLabelsSubset( + parentInput.getSourceBlock() as BlockSvg, + parentInput, + ).join(','); + if (this.type === ConnectionType.NEXT_STATEMENT) { + aria.setState( + highlightSvg, + aria.State.LABEL, + Msg['INPUT_LABEL_END_STATEMENT'].replace('%1', parentInputLabel), + ); + } else { + aria.setState(highlightSvg, aria.State.LABEL, parentInputLabel); + } + } + /** Add highlighting around this connection. */ highlight() { this.highlighted = true; @@ -332,6 +386,7 @@ export class RenderedConnection if (highlightSvg) { highlightSvg.style.display = ''; highlightSvg.parentElement?.appendChild(highlightSvg); + this.recomputeAriaContext(highlightSvg); } } @@ -656,7 +711,9 @@ export class RenderedConnection /** See IFocusableNode.canBeFocused. */ canBeFocused(): boolean { - return true; + // Since the highlightSvg is the focusable element, + // if it doesn't exist then the connection can't be focused. + return this.findHighlightSvg() !== null; } private findHighlightSvg(): SVGPathElement | null { diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 4f2135a9761..ba1cba11bff 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-05-11 14:15:58.197621", + "lastupdated": "2026-05-12 16:03:06.800029", "locale": "en", "messagedocumentation" : "qqq" }, @@ -479,6 +479,10 @@ "BLOCK_LABEL_VALUE": "value", "BLOCK_LABEL_STACK_BLOCKS": "%1 stack blocks", "INPUT_LABEL_INDEX": "input %1", + "INPUT_LABEL_VALUE": "value position", + "INPUT_LABEL_STATEMENT": "command position", + "INPUT_LABEL_END_STATEMENT": "End %1", + "INPUT_LABEL_EMPTY": "Empty", "ANNOUNCE_MOVE_WORKSPACE": "Moving %1 on workspace.", "ANNOUNCE_MOVE_BEFORE": "Moving %1 before %2.", "ANNOUNCE_MOVE_AFTER": "Moving %1 after %2.", diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 49e642f935f..5c0391e1b73 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -487,6 +487,10 @@ "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.", "BLOCK_LABEL_STACK_BLOCKS": "Accessibility label for a block that indicates it is a stack of two or more blocks.", "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", + "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.", + "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.", + "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.", + "INPUT_LABEL_EMPTY": "Accessibility label describing an empty connection point that doesn't meet any other criteria for getting a more specific connection label.", "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.", "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.'", "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.'", diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 71c3aff5ce8..ae778048c85 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1900,6 +1900,24 @@ Blockly.Msg.BLOCK_LABEL_STACK_BLOCKS = '%1 stack blocks'; /// \n\nParameters:\n* %1 - the index of the input, starting at 1 Blockly.Msg.INPUT_LABEL_INDEX = 'input %1'; /** @type {string} */ +/// Accessibility label for an empty connection that can hold a value block. +/// This should use the same language as the BLOCK_LABEL_VALUE string. +Blockly.Msg.INPUT_LABEL_VALUE = 'value position'; +/** @type {string} */ +/// 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. +Blockly.Msg.INPUT_LABEL_STATEMENT = 'command position'; +/** @type {string} */ +/// 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. +Blockly.Msg.INPUT_LABEL_END_STATEMENT = 'End %1'; +/** @type {string} */ +/// Accessibility label describing an empty connection point that doesn't +/// meet any other criteria for getting a more specific connection label. +Blockly.Msg.INPUT_LABEL_EMPTY = 'Empty'; +/** @type {string} */ /// ARIA live region message announcing a block is being moved on the workspace, without specifying a target location or specific movement direction. // \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks // \n\nExamples:\n* "Moving if, do on workspace."\n* "Moving 2 stack blocks on workspace." From 148f1bcd00cd575660fbeef84d1a9e65fca0c33a Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 12 May 2026 16:50:54 -0400 Subject: [PATCH 2/4] chore: add tests --- packages/blockly/tests/mocha/aria_test.js | 111 ++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 39d540d0f3f..655a3587af4 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {getInputLabelsSubset} from '../../build/src/core/block_aria_composer.js'; import {assert} from '../../node_modules/chai/index.js'; import { sharedTestSetup, @@ -452,4 +453,114 @@ suite('ARIA', function () { assert.isTrue(label.endsWith('has inputs')); }); }); + + suite('Rendered connection highlight ARIA', function () { + function assertHighlightAria( + connection, + expectedRoleDescription, + labelSubstring, + ...moreLabelSubstrings + ) { + const labelSubstrings = [labelSubstring, ...moreLabelSubstrings].flat(); + connection.highlight(); + try { + const el = connection.getFocusableElement(); + assert.equal( + Blockly.utils.aria.getRole(el), + Blockly.utils.aria.Role.FIGURE, + ); + assert.equal( + Blockly.utils.aria.getState( + el, + Blockly.utils.aria.State.ROLEDESCRIPTION, + ), + expectedRoleDescription, + ); + const label = Blockly.utils.aria.getState( + el, + Blockly.utils.aria.State.LABEL, + ); + for (const fragment of labelSubstrings) { + assert.include(label, fragment); + } + } finally { + connection.unhighlight(); + } + } + + setup(function () { + this.renderBlock = (blockType) => { + const block = this.workspace.newBlock(blockType); + block.initSvg(); + block.render(); + return block; + }; + }); + + test('value input connection uses value role description and computed label', function () { + const negate = this.renderBlock('logic_negate'); + const boolInput = negate.getInput('BOOL'); + assertHighlightAria( + boolInput.connection, + Blockly.Msg.INPUT_LABEL_VALUE, + 'not', + ); + }); + + test('empty statement input connection uses statement role description and end label', function () { + const repeat = this.renderBlock('controls_repeat_ext'); + const doInput = repeat.getInput('DO'); + assertHighlightAria( + doInput.connection, + Blockly.Msg.INPUT_LABEL_STATEMENT, + ['End', ...getInputLabelsSubset(repeat, doInput)], + ); + }); + + test('last next connection in a populated statement stack uses statement role description and end label', function () { + const repeat = this.renderBlock('controls_repeat_ext'); + const printBlock = this.renderBlock('text_print'); + const doInput = repeat.getInput('DO'); + doInput.connection.connect(printBlock.previousConnection); + + assertHighlightAria( + printBlock.nextConnection, + Blockly.Msg.INPUT_LABEL_STATEMENT, + ['End', ...getInputLabelsSubset(repeat, doInput)], + ); + }); + + test('value input connection with custom input label uses custom label', function () { + const negate = this.renderBlock('logic_negate'); + negate.getInput('BOOL').setAriaLabelProvider('custom value input'); + assertHighlightAria( + negate.getInput('BOOL').connection, + Blockly.Msg.INPUT_LABEL_VALUE, + 'custom value input', + ); + }); + + test('empty statement input connection with custom input label uses end-of-statement label', function () { + const repeat = this.renderBlock('controls_repeat_ext'); + repeat.getInput('DO').setAriaLabelProvider('custom repeat body'); + assertHighlightAria( + repeat.getInput('DO').connection, + Blockly.Msg.INPUT_LABEL_STATEMENT, + ['End', 'custom repeat body'], + ); + }); + + test('last next connection in a populated statement stack respects custom statement input label', function () { + const repeat = this.renderBlock('controls_repeat_ext'); + repeat.getInput('DO').setAriaLabelProvider('custom repeat body'); + const printBlock = this.renderBlock('text_print'); + repeat.getInput('DO').connection.connect(printBlock.previousConnection); + + assertHighlightAria( + printBlock.nextConnection, + Blockly.Msg.INPUT_LABEL_STATEMENT, + ['End', 'custom repeat body'], + ); + }); + }); }); From b7be68cdefb41150639fccd75ee0235e942c1c20 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 12 May 2026 17:22:49 -0400 Subject: [PATCH 3/4] chore: fix tests --- packages/blockly/tests/mocha/navigation_test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/blockly/tests/mocha/navigation_test.js b/packages/blockly/tests/mocha/navigation_test.js index 3b9660a4c16..92cb2dc8bba 100644 --- a/packages/blockly/tests/mocha/navigation_test.js +++ b/packages/blockly/tests/mocha/navigation_test.js @@ -417,6 +417,13 @@ suite('Navigation', function () { this.blocks.buttonInput2 = buttonInput2; this.blocks.buttonNext = buttonNext; + // Blocks have to be rendered for their connections + // to be focusable. + this.workspace.getAllBlocks().forEach((block) => { + block.initSvg(); + block.render(); + }); + this.workspace.cleanUp(); }); suite('Next', function () { From ba07dab8c1a292f8358586e76b9241c73c147eb1 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 12 May 2026 17:33:04 -0400 Subject: [PATCH 4/4] chore: typo --- packages/blockly/core/rendered_connection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/core/rendered_connection.ts b/packages/blockly/core/rendered_connection.ts index 5a26704dfb4..faa448d3a10 100644 --- a/packages/blockly/core/rendered_connection.ts +++ b/packages/blockly/core/rendered_connection.ts @@ -360,7 +360,7 @@ export class RenderedConnection getInputLabelsSubset( parentInput.getSourceBlock() as BlockSvg, parentInput, - ).join(','); + ).join(', '); if (this.type === ConnectionType.NEXT_STATEMENT) { aria.setState( highlightSvg,