From 05a869be615e954068e18b2a3b3cdb7ce504412b Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 13 May 2026 16:20:25 -0400 Subject: [PATCH] feat: use custom labels for block parent input labels --- packages/blockly/core/block_aria_composer.ts | 67 +++++++++++++----- packages/blockly/tests/mocha/aria_test.js | 73 +++++++++++++++++++- 2 files changed, 120 insertions(+), 20 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index d99e1b33b8c..0d2433b5a6c 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -154,10 +154,24 @@ export function computeFieldRowLabel( } /** - * Returns a description of the parent statement input a block is attached to. - * When a block is connected to a statement input, the input's field row label - * will be prepended to the block's description to indicate that the block - * begins a clause in its parent block. + * Returns a description of the parent input a block is attached to. + * When a block is connected to an input, the input's label will sometimes + * be prepended to the block's description. + * + * If an input has a custom label, the custom label will be prepended + * to the first child block connected to that input. + * + * If an input does not have a custom label, the input's fallback + * label determined from the field row will be prepended to the + * child block's label only if the following are true: + * - the parent block has at least one statement input + * - the child block in question is not attached to the first + * statement input of the parent block (in this case, the label + * would be redundant with the parent block's label) + * + * For statement inputs, the resolved label (whether custom or fallback) is + * wrapped in the "Begin %1" prefix so the readout indicates that the child + * block starts the body of the statement input. * * @internal * @param block The block to generate a parent input label for. @@ -168,24 +182,41 @@ function getParentInputLabel(block: BlockSvg) { const parentInput = ( block.outputConnection ?? block.previousConnection )?.targetConnection?.getParentInput(); - const parentBlock = parentInput?.getSourceBlock(); + if (!parentInput) return undefined; + + const parentBlock = parentInput.getSourceBlock(); + if (parentBlock.isInsertionMarker()) return undefined; + + // parentInput is only non-null when this block is directly attached to the + // input (i.e. it is the first child block in that input). A custom label + // is always prepended for the first child; a fallback label from the field + // row is only used in select circumstances. + let inputLabel: string | string[]; + const customLabel = parentInput.getAriaLabelText(); + if (customLabel) { + inputLabel = customLabel; + } else { + if (!parentBlock.statementInputCount) return undefined; - if (parentBlock?.isInsertionMarker()) return undefined; - if (!parentBlock?.statementInputCount) return undefined; + const firstStatementInput = parentBlock.inputList.find( + (i) => i.type === inputTypes.STATEMENT, + ); + // The first statement input in a block has no field row label as it would + // be duplicative of the block's label. + if (parentInput === firstStatementInput) { + return undefined; + } - const firstStatementInput = parentBlock.inputList.find( - (i) => i.type === inputTypes.STATEMENT, - ); - // The first statement input in a block has no field row label as it would - // be duplicative of the block's label. - if (!parentInput || parentInput === firstStatementInput) { - return undefined; + inputLabel = computeFieldRowLabel(parentInput, true); } - const parentInputLabel = computeFieldRowLabel(parentInput, true); - return parentInput.type === inputTypes.STATEMENT - ? Msg['BLOCK_LABEL_BEGIN_PREFIX'].replace('%1', parentInputLabel.join(' ')) - : parentInputLabel; + if (parentInput.type === inputTypes.STATEMENT) { + const labelText = Array.isArray(inputLabel) + ? inputLabel.join(' ') + : inputLabel; + return Msg['BLOCK_LABEL_BEGIN_PREFIX'].replace('%1', labelText); + } + return inputLabel; } /** diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 39d540d0f3f..a0fc018ebb4 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -371,7 +371,7 @@ suite('ARIA', function () { assert.notInclude(label, 'Begin stack'); }); - test('Nested statement blocks in first statement input do not include their parent input in their label', function () { + test('Statement blocks in first statement input do not include their parent input in their label', function () { const ifBlock = this.makeBlock('controls_ifelse'); const printBlock = this.makeBlock('text_print'); ifBlock.getInput('IF0').connection.connect(printBlock.previousConnection); @@ -382,7 +382,7 @@ suite('ARIA', function () { assert.isFalse(label.startsWith('Begin do')); }); - test('Nested statement blocks in subsequent statement inputs include their parent input in their label', function () { + test('Statement blocks in subsequent statement inputs include their parent input in their label', function () { const ifBlock = this.makeBlock('controls_ifelse'); const printBlock = this.makeBlock('text_print'); ifBlock @@ -395,6 +395,75 @@ suite('ARIA', function () { assert.isTrue(label.startsWith('Begin else')); }); + test('A custom statement input label is wrapped in the "Begin" prefix', function () { + const ifBlock = this.makeBlock('controls_ifelse'); + ifBlock.getInput('ELSE').setAriaLabelProvider('otherwise do'); + const printBlock = this.makeBlock('text_print'); + ifBlock + .getInput('ELSE') + .connection.connect(printBlock.previousConnection); + const label = Blockly.utils.aria.getState( + printBlock.getFocusableElement(), + Blockly.utils.aria.State.LABEL, + ); + assert.include(label, 'Begin otherwise do'); + }); + + test('A custom label on the first statement input is prepended to its child block label', function () { + const ifBlock = this.makeBlock('controls_ifelse'); + ifBlock.getInput('DO0').setAriaLabelProvider('then do'); + const printBlock = this.makeBlock('text_print'); + ifBlock.getInput('DO0').connection.connect(printBlock.previousConnection); + const label = Blockly.utils.aria.getState( + printBlock.getFocusableElement(), + Blockly.utils.aria.State.LABEL, + ); + assert.include(label, 'Begin then do'); + }); + + test('A custom input label is only used for the first child block in a statement input stack', function () { + const ifBlock = this.makeBlock('controls_ifelse'); + ifBlock.getInput('ELSE').setAriaLabelProvider('otherwise do'); + const firstPrintBlock = this.makeBlock('text_print'); + ifBlock + .getInput('ELSE') + .connection.connect(firstPrintBlock.previousConnection); + const secondPrintBlock = this.makeBlock('text_print'); + firstPrintBlock.nextConnection.connect( + secondPrintBlock.previousConnection, + ); + const subsequentLabel = Blockly.utils.aria.getState( + secondPrintBlock.getFocusableElement(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(subsequentLabel, 'otherwise do'); + }); + + test('A custom input label is prepended to the child block of a value input', function () { + const ifBlock = this.makeBlock('controls_ifelse'); + ifBlock.getInput('IF0').setAriaLabelProvider('condition'); + const boolBlock = this.makeBlock('logic_boolean'); + ifBlock.getInput('IF0').connection.connect(boolBlock.outputConnection); + const label = Blockly.utils.aria.getState( + boolBlock.getFocusableElement(), + Blockly.utils.aria.State.LABEL, + ); + assert.include(label, 'condition'); + }); + + test('A block connected to a value input without a custom label does not include the input label', function () { + const negateBlock = this.makeBlock('logic_negate'); + const boolBlock = this.makeBlock('logic_boolean'); + negateBlock + .getInput('BOOL') + .connection.connect(boolBlock.outputConnection); + const label = Blockly.utils.aria.getState( + boolBlock.getFocusableElement(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'not'); + }); + test('Disabled blocks indicate that in their label', function () { const block = this.makeBlock('text_print'); let label = Blockly.utils.aria.getState(