Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/blockly/core/block_aria_composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
59 changes: 58 additions & 1 deletion packages/blockly/core/rendered_connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -332,6 +386,7 @@ export class RenderedConnection
if (highlightSvg) {
highlightSvg.style.display = '';
highlightSvg.parentElement?.appendChild(highlightSvg);
this.recomputeAriaContext(highlightSvg);
}
}

Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion packages/blockly/msg/json/en.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2026-05-13 09:26:53.422108",
"lastupdated": "2026-05-12 16:03:06.800029",
"locale": "en",
"messagedocumentation" : "qqq"
},
Expand Down Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions packages/blockly/msg/json/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.'",
Expand Down
18 changes: 18 additions & 0 deletions packages/blockly/msg/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
111 changes: 111 additions & 0 deletions packages/blockly/tests/mocha/aria_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
);
});
});
});
7 changes: 7 additions & 0 deletions packages/blockly/tests/mocha/navigation_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down