diff --git a/core/block_svg.ts b/core/block_svg.ts index 86af67be8c1..a865593a2ff 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -234,6 +234,54 @@ export class BlockSvg * @internal */ recomputeAriaLabel() { + if (this.initialized) { + const childElemIds: string[] = []; + for (const input of this.inputList) { + const connection = input.connection as RenderedConnection | null; + if (input.isVisible() && connection) { + if (connection.type === ConnectionType.NEXT_STATEMENT) { + let currentBlock: BlockSvg | null = connection.targetBlock(); + while (currentBlock) { + if (currentBlock.canBeFocused()) { + childElemIds.push(currentBlock.getBlockSvgFocusElem().id); + } + currentBlock = currentBlock.getNextBlock(); + } + } else if (connection.type === ConnectionType.INPUT_VALUE) { + const inpBlock = connection.targetBlock() as BlockSvg | null; + if (inpBlock && inpBlock.canBeFocused()) { + childElemIds.push(inpBlock.getBlockSvgFocusElem().id); + } + if (connection.canBeFocused()) { + childElemIds.push(connection.getFocusableElement().id); + } + } + } + for (const field of input.fieldRow) { + if (field.getSvgRoot() && field.canBeFocused()) { + // Only track the field if it's been initialized. + childElemIds.push(field.getFocusableElement().id); + } + } + for (const icon of this.icons) { + if (icon.canBeFocused()) { + childElemIds.push(icon.getFocusableElement().id); + } + } + } + + const nextConnection = this.nextConnection as RenderedConnection | null; + if ( + nextConnection && + nextConnection.canBeFocused() && + nextConnection.type === ConnectionType.NEXT_STATEMENT + ) { + childElemIds.push(nextConnection.getFocusableElement().id); + } + + aria.setState(this.getBlockSvgFocusElem(), aria.State.OWNS, childElemIds); + } + if (this.isSimpleReporter(true, true)) return; aria.setState( @@ -243,6 +291,12 @@ export class BlockSvg ); } + private getBlockSvgFocusElem(): Element { + // Note that this deviates from getFocusableElement() to ensure that + // single field blocks are properly set up in the hierarchy. + return this.pathObject.svgPath; + } + computeAriaLabel( verbose: boolean = false, minimal: boolean = false, @@ -359,6 +413,7 @@ export class BlockSvg this.workspace.getCanvas().appendChild(svg); } this.initialized = true; + this.recomputeAriaLabel(); } /** @@ -463,6 +518,12 @@ export class BlockSvg this.applyColour(); this.workspace.recomputeAriaTree(); + this.recomputeAriaLabelRecursive(); + } + + private recomputeAriaLabelRecursive() { + this.recomputeAriaLabel(); + this.parentBlock_?.recomputeAriaLabelRecursive(); } /** diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index f824cdb38f9..ffef106f579 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -688,7 +688,7 @@ export class RenderedConnection /** See IFocusableNode.canBeFocused. */ canBeFocused(): boolean { - return true; + return this.findHighlightSvg() !== null; } private findHighlightSvg(): SVGPathElement | null { diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index 2273ec4b381..6816cf8c1e7 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -342,7 +342,7 @@ suite('Cursor', function () { }); suite('one empty block', function () { setup(function () { - this.blockA = this.workspace.newBlock('empty_block'); + this.blockA = createRenderedBlock(this.workspace, 'empty_block'); }); teardown(function () { this.workspace.clear(); @@ -359,7 +359,7 @@ suite('Cursor', function () { suite('one stack block', function () { setup(function () { - this.blockA = this.workspace.newBlock('stack_block'); + this.blockA = createRenderedBlock(this.workspace, 'stack_block'); }); teardown(function () { this.workspace.clear(); @@ -376,7 +376,7 @@ suite('Cursor', function () { suite('one row block', function () { setup(function () { - this.blockA = this.workspace.newBlock('row_block'); + this.blockA = createRenderedBlock(this.workspace, 'row_block'); }); teardown(function () { this.workspace.clear(); @@ -392,7 +392,7 @@ suite('Cursor', function () { }); suite('one c-hat block', function () { setup(function () { - this.blockA = this.workspace.newBlock('c_hat_block'); + this.blockA = createRenderedBlock(this.workspace, 'c_hat_block'); }); teardown(function () { this.workspace.clear(); diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js index 3a9292b9209..37972318d50 100644 --- a/tests/mocha/navigation_test.js +++ b/tests/mocha/navigation_test.js @@ -5,10 +5,10 @@ */ import {assert} from '../../node_modules/chai/index.js'; +import {createRenderedBlock} from './test_helpers/block_definitions.js'; import { sharedTestSetup, sharedTestTeardown, - workspaceTeardown, } from './test_helpers/setup_teardown.js'; suite('Navigation', function () { @@ -89,13 +89,28 @@ suite('Navigation', function () { ]); this.workspace = Blockly.inject('blocklyDiv', {}); this.navigator = this.workspace.getNavigator(); - const statementInput1 = this.workspace.newBlock('input_statement'); - const statementInput2 = this.workspace.newBlock('input_statement'); - const statementInput3 = this.workspace.newBlock('input_statement'); - const statementInput4 = this.workspace.newBlock('input_statement'); - const fieldWithOutput = this.workspace.newBlock('field_input'); - const doubleValueInput = this.workspace.newBlock('double_value_input'); - const valueInput = this.workspace.newBlock('value_input'); + const statementInput1 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const statementInput2 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const statementInput3 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const statementInput4 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const fieldWithOutput = createRenderedBlock(this.workspace, 'field_input'); + const doubleValueInput = createRenderedBlock( + this.workspace, + 'double_value_input', + ); + const valueInput = createRenderedBlock(this.workspace, 'value_input'); statementInput1.nextConnection.connect(statementInput2.previousConnection); statementInput1.inputList[0].connection.connect( @@ -355,13 +370,25 @@ suite('Navigation', function () { 'helpUrl': '', }, ]); - const noNextConnection = this.workspace.newBlock('top_connection'); - const fieldAndInputs = this.workspace.newBlock('fields_and_input'); - const twoFields = this.workspace.newBlock('two_fields'); - const fieldAndInputs2 = this.workspace.newBlock('fields_and_input2'); - const noPrevConnection = this.workspace.newBlock('start_block'); - const hiddenField = this.workspace.newBlock('hidden_field'); - const hiddenInput = this.workspace.newBlock('hidden_input'); + const noNextConnection = createRenderedBlock( + this.workspace, + 'top_connection', + ); + const fieldAndInputs = createRenderedBlock( + this.workspace, + 'fields_and_input', + ); + const twoFields = createRenderedBlock(this.workspace, 'two_fields'); + const fieldAndInputs2 = createRenderedBlock( + this.workspace, + 'fields_and_input2', + ); + const noPrevConnection = createRenderedBlock( + this.workspace, + 'start_block', + ); + const hiddenField = createRenderedBlock(this.workspace, 'hidden_field'); + const hiddenInput = createRenderedBlock(this.workspace, 'hidden_input'); this.blocks.noNextConnection = noNextConnection; this.blocks.fieldAndInputs = fieldAndInputs; this.blocks.twoFields = twoFields; @@ -373,28 +400,47 @@ suite('Navigation', function () { hiddenField.inputList[0].fieldRow[1].setVisible(false); hiddenInput.inputList[1].setVisible(false); - const dummyInput = this.workspace.newBlock('dummy_input'); - const dummyInputValue = this.workspace.newBlock('dummy_inputValue'); - const fieldWithOutput2 = this.workspace.newBlock('field_input'); + const dummyInput = createRenderedBlock(this.workspace, 'dummy_input'); + const dummyInputValue = createRenderedBlock( + this.workspace, + 'dummy_inputValue', + ); + const fieldWithOutput2 = createRenderedBlock( + this.workspace, + 'field_input', + ); this.blocks.dummyInput = dummyInput; this.blocks.dummyInputValue = dummyInputValue; this.blocks.fieldWithOutput2 = fieldWithOutput2; - const secondBlock = this.workspace.newBlock('input_statement'); - const outputNextBlock = this.workspace.newBlock('output_next'); + const secondBlock = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const outputNextBlock = createRenderedBlock( + this.workspace, + 'output_next', + ); this.blocks.secondBlock = secondBlock; this.blocks.outputNextBlock = outputNextBlock; - const buttonBlock = this.workspace.newBlock('buttons', 'button_block'); - const buttonInput1 = this.workspace.newBlock( + const buttonBlock = createRenderedBlock( + this.workspace, + 'buttons', + 'button_block', + ); + const buttonInput1 = createRenderedBlock( + this.workspace, 'field_input', 'button_input1', ); - const buttonInput2 = this.workspace.newBlock( + const buttonInput2 = createRenderedBlock( + this.workspace, 'field_input', 'button_input2', ); - const buttonNext = this.workspace.newBlock( + const buttonNext = createRenderedBlock( + this.workspace, 'input_statement', 'button_next', ); @@ -420,15 +466,6 @@ suite('Navigation', function () { this.workspace.cleanUp(); }); suite('Next', function () { - setup(function () { - this.singleBlockWorkspace = new Blockly.Workspace(); - const singleBlock = this.singleBlockWorkspace.newBlock('two_fields'); - this.blocks.singleBlock = singleBlock; - }); - teardown(function () { - workspaceTeardown.call(this, this.singleBlockWorkspace); - }); - test('fromPreviousToBlock', function () { const prevConnection = this.blocks.statementInput1.previousConnection; const nextNode = this.navigator.getNextSibling(prevConnection); @@ -471,8 +508,6 @@ suite('Navigation', function () { }); test('fromFieldToNestedBlock', function () { const field = this.blocks.statementInput1.inputList[0].fieldRow[1]; - const inputConnection = - this.blocks.statementInput1.inputList[0].connection; const nextNode = this.navigator.getNextSibling(field); assert.equal(nextNode, this.blocks.fieldWithOutput); }); @@ -576,7 +611,6 @@ suite('Navigation', function () { const prevNode = this.navigator.getPreviousSibling( this.blocks.fieldWithOutput, ); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; assert.equal(prevNode, [...this.blocks.statementInput1.getFields()][1]); }); test('fromNextToBlock', function () { @@ -617,14 +651,12 @@ suite('Navigation', function () { assert.isNull(prevNode); }); test('fromFieldToInput', function () { - const outputBlock = this.workspace.newBlock('field_input'); + const outputBlock = createRenderedBlock(this.workspace, 'field_input'); this.blocks.fieldAndInputs2.inputList[0].connection.connect( outputBlock.outputConnection, ); const field = this.blocks.fieldAndInputs2.inputList[1].fieldRow[0]; - const inputConnection = - this.blocks.fieldAndInputs2.inputList[0].connection; const prevNode = this.navigator.getPreviousSibling(field); assert.equal(prevNode, outputBlock); }); @@ -701,18 +733,13 @@ suite('Navigation', function () { }); suite('In', function () { - setup(function () { - this.emptyWorkspace = Blockly.inject(document.createElement('div'), {}); - }); - teardown(function () { - workspaceTeardown.call(this, this.emptyWorkspace); - }); - test('fromInputToOutput', function () { + // The first child is the connected block since the connection itself + // cannot be navigated to directly. const input = this.blocks.statementInput1.inputList[0]; const inNode = this.navigator.getFirstChild(input.connection); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; - assert.equal(inNode, outputConnection); + const connectedBlock = this.blocks.fieldWithOutput; + assert.equal(inNode, connectedBlock); }); test('fromInputToNull', function () { const input = this.blocks.statementInput2.inputList[0]; diff --git a/tests/mocha/test_helpers/block_definitions.js b/tests/mocha/test_helpers/block_definitions.js index 26507b29cb8..e5ca106d2c4 100644 --- a/tests/mocha/test_helpers/block_definitions.js +++ b/tests/mocha/test_helpers/block_definitions.js @@ -196,8 +196,8 @@ export function createTestBlock() { }; } -export function createRenderedBlock(workspaceSvg, type) { - const block = workspaceSvg.newBlock(type); +export function createRenderedBlock(workspaceSvg, type, opt_id) { + const block = workspaceSvg.newBlock(type, opt_id); block.initSvg(); block.render(); return block;