diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 0d2433b5a6c..85f9de68751 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -284,7 +284,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..faa448d3a10 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 a643fd8f9b7..04a146a4a61 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-13 09:26:53.422108", + "lastupdated": "2026-05-12 16:03:06.800029", "locale": "en", "messagedocumentation" : "qqq" }, @@ -480,6 +480,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 bdb9ce1952b..68312cd952e 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -474,6 +474,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 943750ff4ee..c6cb9cf7fed 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1903,6 +1903,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." diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index a0fc018ebb4..f03ba068814 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, @@ -521,4 +522,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'], + ); + }); + }); }); 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 () {