From 01f6e84a1a8edc80c5017fdacba146be8f35f32b Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:56:40 -0400 Subject: [PATCH 1/4] feat: Add relevant things to full-block field labels --- packages/blockly/core/block_aria_composer.ts | 9 +--- packages/blockly/core/field_dropdown.ts | 10 +++- packages/blockly/core/field_input.ts | 32 ++++++++++- .../tests/mocha/field_dropdown_test.js | 53 +++++++++++++++++++ .../blockly/tests/mocha/field_number_test.js | 48 +++++++++++++++++ 5 files changed, 141 insertions(+), 11 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index e39de955127..f8ffd68d27f 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -63,13 +63,6 @@ export function computeAriaLabel( block: BlockSvg, verbosity = Verbosity.STANDARD, ) { - if (block.isSimpleReporter()) { - // special case for full-block field blocks. - const field = block.getFullBlockField(); - if (field) { - return field.computeAriaLabel(verbosity >= Verbosity.STANDARD); - } - } return [ verbosity >= Verbosity.STANDARD && getBeginStackLabel(block), getParentInputLabel(block), @@ -271,7 +264,7 @@ function getParentInputLabel(block: BlockSvg) { * @returns Text indicating that the block begins a stack, or undefined if it * does not. */ -function getBeginStackLabel(block: BlockSvg) { +export function getBeginStackLabel(block: BlockSvg) { // Don't include the "begin stack" label for blocks that are moving // or blocks in the flyout if (block.isInFlyout || block.isDragging()) return undefined; diff --git a/packages/blockly/core/field_dropdown.ts b/packages/blockly/core/field_dropdown.ts index 11e3f6282a8..48fa9abd117 100644 --- a/packages/blockly/core/field_dropdown.ts +++ b/packages/blockly/core/field_dropdown.ts @@ -13,6 +13,7 @@ */ // Former goog.module ID: Blockly.FieldDropdown +import {computeAriaLabel} from './block_aria_composer.js'; import type {BlockSvg} from './block_svg.js'; import * as dropDownDiv from './dropdowndiv.js'; import { @@ -940,7 +941,14 @@ export class FieldDropdown extends Field { if (!shouldCustomize) return false; const focusableElement = this.getFocusableElement(); - const label = this.computeAriaLabel(true); + let label = this.computeAriaLabel(true); + if (this.isFullBlockField()) { + // Full block fields get a more detailed label that includes the block's label + label = computeAriaLabel(this.getSourceBlock() as BlockSvg).replace( + this.computeAriaLabel(false), + label, + ); + } aria.setState(focusableElement, aria.State.LABEL, label); aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox'); diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index 0da8371c015..cfa1003ae60 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -14,6 +14,7 @@ // Unused import preserved for side-effects. Remove if unneeded. import './events/events_block_change.js'; +import {computeAriaLabel, getBeginStackLabel} from './block_aria_composer.js'; import {BlockSvg} from './block_svg.js'; import * as browserEvents from './browser_events.js'; import * as bumpObjects from './bump_objects.js'; @@ -855,8 +856,35 @@ export abstract class FieldInput extends Field< const focusableElement = this.getFocusableElement(); let label = this.computeAriaLabel(true); - if (this.isCurrentlyEditable() && !this.getSourceBlock()?.isInFlyout) { - label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label); + const requiresEditableLabel = + this.isCurrentlyEditable() && !this.getSourceBlock()?.isInFlyout; + + if (!this.isFullBlockField()) { + if (requiresEditableLabel) { + label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label); + } + } else { + // Full block fields get a more detailed label that includes the block's label + const fullBlockLabel = computeAriaLabel( + this.getSourceBlock() as BlockSvg, + ).replace(this.computeAriaLabel(false), label); + if (requiresEditableLabel) { + const labels = fullBlockLabel.split(', '); + const beginStackLabel = getBeginStackLabel( + this.getSourceBlock() as BlockSvg, + ); + + // Insert "Edit" after "Begin stack" if found, otherwise at start. + const beginStackLabelIndex = + beginStackLabel === undefined ? -1 : labels.indexOf(beginStackLabel); + const insertIndex = + beginStackLabelIndex === -1 ? 0 : beginStackLabelIndex + 1; + labels[insertIndex] = Msg['FIELD_LABEL_EDIT_PREFIX'].replace( + '%1', + labels[insertIndex] ?? '', + ); + label = labels.join(', '); + } } aria.setState(focusableElement, aria.State.LABEL, label); return true; diff --git a/packages/blockly/tests/mocha/field_dropdown_test.js b/packages/blockly/tests/mocha/field_dropdown_test.js index cae3fb4d0fb..2ee51844bf5 100644 --- a/packages/blockly/tests/mocha/field_dropdown_test.js +++ b/packages/blockly/tests/mocha/field_dropdown_test.js @@ -580,5 +580,58 @@ suite('Dropdown Fields', function () { assert.include(label, 'Option 5'); }); }); + suite('Full block fields', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'zelos', + }); + this.block = this.workspace.newBlock('variables_get'); + this.block.initSvg(); + this.block.render(); + this.field = this.block.getField('VAR'); + }); + + test('Top block ARIA label includes "Begin stack" label before dropdown field label', function () { + const labels = this.block + .getFocusableElement() + .getAttribute('aria-label') + .split(', '); + + const expectedBeginStackLabel = 'Begin stack'; + const expectedFieldLabel = "dropdown: Variable 'item'"; + assert.include(labels, expectedBeginStackLabel); + assert.include(labels, expectedFieldLabel); + assert.isTrue( + labels.indexOf(expectedBeginStackLabel) < + labels.indexOf(expectedFieldLabel), + ); + }); + + test('Child block ARIA label includes parent input custom label before dropdown field label', function () { + const parentBlock = this.workspace.newBlock('controls_repeat_ext'); + parentBlock.initSvg(); + parentBlock.render(); + + this.block.outputConnection.connect( + parentBlock.getInput('TIMES').connection, + ); + + this.block.getField('VAR').recomputeAriaContext(); + + const labels = this.block + .getFocusableElement() + .getAttribute('aria-label') + .split(', '); + + const expectedInputLabel = 'number of times to repeat'; + const expectedFieldLabel = "dropdown: Variable 'item'"; + assert.include(labels, expectedInputLabel); + assert.include(labels, expectedFieldLabel); + assert.isTrue( + labels.indexOf(expectedInputLabel) < + labels.indexOf(expectedFieldLabel), + ); + }); + }); }); }); diff --git a/packages/blockly/tests/mocha/field_number_test.js b/packages/blockly/tests/mocha/field_number_test.js index 59d82b4b141..616fee56daa 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -551,5 +551,53 @@ suite('Number Fields', function () { const updatedLabel = this.focusableElement.getAttribute('aria-label'); assert.isTrue(updatedLabel.includes('1')); }); + suite('Full block fields', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'zelos', + }); + this.block = this.workspace.newBlock('math_number'); + this.field = this.block.getField('NUM'); + this.block.initSvg(); + this.block.render(); + }); + test('Top block ARIA label includes "Begin stack" label before expected field label', function () { + const labels = this.block + .getFocusableElement() + .getAttribute('aria-label') + .split(', '); + + const expectedBeginStackLabel = 'Begin stack'; + const expectedFieldLabel = 'Edit number: 0'; + assert.include(labels, expectedBeginStackLabel); + assert.include(labels, expectedFieldLabel); + assert.isTrue( + labels.indexOf(expectedBeginStackLabel) < + labels.indexOf(expectedFieldLabel), + ); + }); + test('Child block ARIA label includes parent input custom label after "Edit" label and before field label', function () { + const parentBlock = this.workspace.newBlock('controls_repeat_ext'); + parentBlock.initSvg(); + parentBlock.render(); + this.block.outputConnection.connect( + parentBlock.getInput('TIMES').connection, + ); + this.block.getField('NUM').recomputeAriaContext(); + const labels = this.block + .getFocusableElement() + .getAttribute('aria-label') + .split(', '); + + const expectedInputLabel = 'Edit number of times to repeat'; + const expectedFieldLabel = 'number: 0'; + assert.include(labels, expectedInputLabel); + assert.include(labels, expectedFieldLabel); + assert.isTrue( + labels.indexOf(expectedInputLabel) < + labels.indexOf(expectedFieldLabel), + ); + }); + }); }); }); From bdca5693b763e393cc20fffdaa0552c161a1abe2 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:23:27 -0400 Subject: [PATCH 2/4] fix: life cycle and input labels override --- packages/blockly/core/block_aria_composer.ts | 9 +++- packages/blockly/core/block_svg.ts | 10 +++- packages/blockly/core/field.ts | 2 +- packages/blockly/core/field_input.ts | 5 +- .../tests/mocha/field_dropdown_test.js | 46 +++++++++++++++++-- .../blockly/tests/mocha/field_number_test.js | 45 +++++++++++++++++- 6 files changed, 108 insertions(+), 9 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index f8ffd68d27f..210bea45216 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -57,16 +57,18 @@ export enum ConnectionPreposition { * @internal * @param block The block for which an ARIA representation should be created. * @param verbosity How much detail to include in the description. + * @param fullBlockFieldLabel An optional override for input labels for full-block fields * @returns The ARIA representation for the specified block. */ export function computeAriaLabel( block: BlockSvg, verbosity = Verbosity.STANDARD, + fullBlockFieldLabel?: string, ) { return [ verbosity >= Verbosity.STANDARD && getBeginStackLabel(block), getParentInputLabel(block), - ...getInputLabels(block, verbosity), + ...getInputLabels(block, verbosity, fullBlockFieldLabel), verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), verbosity >= Verbosity.STANDARD && getDisabledLabel(block), verbosity >= Verbosity.STANDARD && getCollapsedLabel(block), @@ -288,12 +290,17 @@ export function getBeginStackLabel(block: BlockSvg) { * @internal * @param block The block to retrieve a list of field/input labels for. * @param verbosity How much detail to include in each input label. + * @param fullBlockFieldLabel An optional override for full-block fields. * @returns A list of field/input labels for the given block. */ export function getInputLabels( block: BlockSvg, verbosity = Verbosity.STANDARD, + fullBlockFieldLabel?: string, ): string[] { + if (fullBlockFieldLabel) { + return [fullBlockFieldLabel]; + } const visibleInputs = block.inputList.filter((input) => input.isVisible()); let inputsToLabel = visibleInputs; diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index e79a70eac97..fba41bb22a8 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -348,6 +348,7 @@ export class BlockSvg } this.applyColour(); + this.recomputeAriaContext(); } /** @@ -791,6 +792,9 @@ export class BlockSvg } else { common.draggingConnections.length = 0; this.removeClass('blocklyDragging'); + if (this.getFullBlockField()) { + this.recomputeAriaContext(); + } } // Recurse through all blocks attached under this one. for (let i = 0; i < this.childBlocks_.length; i++) { @@ -2038,7 +2042,11 @@ export class BlockSvg * Updates the ARIA label, role and roledescription for this block. */ private recomputeAriaContext() { - if (this.getFullBlockField()) return; + const fullBlockField = this.getFullBlockField(); + if (fullBlockField) { + fullBlockField.recomputeAriaContext(); + return; + } aria.setState( this.getFocusableElement(), aria.State.LABEL, diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index 0c3e05d4e54..6b0def64738 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -1518,7 +1518,7 @@ export abstract class Field * * @returns true if the element is in the accessibility tree, false if the aria state is hidden */ - protected recomputeAriaContext(): boolean { + recomputeAriaContext(): boolean { let focusableElement; try { focusableElement = this.getFocusableElement(); diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index cfa1003ae60..3c9db623cb6 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -33,6 +33,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; import * as aria from './utils/aria.js'; +import {Verbosity} from './utils/aria.js'; import * as dom from './utils/dom.js'; import {Size} from './utils/size.js'; import * as userAgent from './utils/useragent.js'; @@ -867,7 +868,9 @@ export abstract class FieldInput extends Field< // Full block fields get a more detailed label that includes the block's label const fullBlockLabel = computeAriaLabel( this.getSourceBlock() as BlockSvg, - ).replace(this.computeAriaLabel(false), label); + Verbosity.STANDARD, + label, + ); if (requiresEditableLabel) { const labels = fullBlockLabel.split(', '); const beginStackLabel = getBeginStackLabel( diff --git a/packages/blockly/tests/mocha/field_dropdown_test.js b/packages/blockly/tests/mocha/field_dropdown_test.js index 2ee51844bf5..e08b2202f60 100644 --- a/packages/blockly/tests/mocha/field_dropdown_test.js +++ b/packages/blockly/tests/mocha/field_dropdown_test.js @@ -590,6 +590,9 @@ suite('Dropdown Fields', function () { this.block.render(); this.field = this.block.getField('VAR'); }); + teardown(function () { + workspaceTeardown.call(this, this.workspace); + }); test('Top block ARIA label includes "Begin stack" label before dropdown field label', function () { const labels = this.block @@ -607,7 +610,7 @@ suite('Dropdown Fields', function () { ); }); - test('Child block ARIA label includes parent input custom label before dropdown field label', function () { + test('Connect to parent updates ARIA label with parent input label', function () { const parentBlock = this.workspace.newBlock('controls_repeat_ext'); parentBlock.initSvg(); parentBlock.render(); @@ -616,8 +619,6 @@ suite('Dropdown Fields', function () { parentBlock.getInput('TIMES').connection, ); - this.block.getField('VAR').recomputeAriaContext(); - const labels = this.block .getFocusableElement() .getAttribute('aria-label') @@ -631,6 +632,45 @@ suite('Dropdown Fields', function () { labels.indexOf(expectedInputLabel) < labels.indexOf(expectedFieldLabel), ); + assert.notInclude(labels, 'Begin stack'); + }); + test('Disconnect from parent updates ARIA label with Begin stack', function () { + const parentBlock = this.workspace.newBlock('controls_repeat_ext'); + parentBlock.initSvg(); + parentBlock.render(); + this.block.outputConnection.connect( + parentBlock.getInput('TIMES').connection, + ); + this.block.outputConnection.disconnect(); + + const label = this.block + .getFocusableElement() + .getAttribute('aria-label'); + assert.include(label, 'Begin stack'); + assert.notInclude(label, 'number of times to repeat'); + }); + test('Disconnect during drag updates ARIA label after drag ends', function () { + const parentBlock = this.workspace.newBlock('controls_repeat_ext'); + parentBlock.initSvg(); + parentBlock.render(); + this.block.outputConnection.connect( + parentBlock.getInput('TIMES').connection, + ); + + this.block.setDragging(true); + this.block.outputConnection.disconnect(); + + const labelWhileDragging = this.block + .getFocusableElement() + .getAttribute('aria-label'); + assert.notInclude(labelWhileDragging, 'Begin stack'); + + this.block.setDragging(false); + + const labelAfterDrag = this.block + .getFocusableElement() + .getAttribute('aria-label'); + assert.include(labelAfterDrag, 'Begin stack'); }); }); }); diff --git a/packages/blockly/tests/mocha/field_number_test.js b/packages/blockly/tests/mocha/field_number_test.js index 616fee56daa..612066b786f 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -561,6 +561,9 @@ suite('Number Fields', function () { this.block.initSvg(); this.block.render(); }); + teardown(function () { + workspaceTeardown.call(this, this.workspace); + }); test('Top block ARIA label includes "Begin stack" label before expected field label', function () { const labels = this.block .getFocusableElement() @@ -576,14 +579,13 @@ suite('Number Fields', function () { labels.indexOf(expectedFieldLabel), ); }); - test('Child block ARIA label includes parent input custom label after "Edit" label and before field label', function () { + test('Connect to parent updates ARIA label with parent input label', function () { const parentBlock = this.workspace.newBlock('controls_repeat_ext'); parentBlock.initSvg(); parentBlock.render(); this.block.outputConnection.connect( parentBlock.getInput('TIMES').connection, ); - this.block.getField('NUM').recomputeAriaContext(); const labels = this.block .getFocusableElement() .getAttribute('aria-label') @@ -597,6 +599,45 @@ suite('Number Fields', function () { labels.indexOf(expectedInputLabel) < labels.indexOf(expectedFieldLabel), ); + assert.notInclude(labels, 'Begin stack'); + }); + test('Disconnect from parent updates ARIA label with Begin stack', function () { + const parentBlock = this.workspace.newBlock('controls_repeat_ext'); + parentBlock.initSvg(); + parentBlock.render(); + this.block.outputConnection.connect( + parentBlock.getInput('TIMES').connection, + ); + this.block.outputConnection.disconnect(); + + const label = this.block + .getFocusableElement() + .getAttribute('aria-label'); + assert.include(label, 'Begin stack'); + assert.notInclude(label, 'number of times to repeat'); + }); + test('Disconnect during drag updates ARIA label after drag ends', function () { + const parentBlock = this.workspace.newBlock('controls_repeat_ext'); + parentBlock.initSvg(); + parentBlock.render(); + this.block.outputConnection.connect( + parentBlock.getInput('TIMES').connection, + ); + + this.block.setDragging(true); + this.block.outputConnection.disconnect(); + + const labelWhileDragging = this.block + .getFocusableElement() + .getAttribute('aria-label'); + assert.notInclude(labelWhileDragging, 'Begin stack'); + + this.block.setDragging(false); + + const labelAfterDrag = this.block + .getFocusableElement() + .getAttribute('aria-label'); + assert.include(labelAfterDrag, 'Begin stack'); }); }); }); From 4c791f4920499416c4db39248b6962c0f0602cfe Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:46:59 -0400 Subject: [PATCH 3/4] fix: closure compiler error --- packages/blockly/core/block_aria_composer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 210bea45216..be433f1b730 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -63,7 +63,7 @@ export enum ConnectionPreposition { export function computeAriaLabel( block: BlockSvg, verbosity = Verbosity.STANDARD, - fullBlockFieldLabel?: string, + fullBlockFieldLabel: string | undefined = undefined, ) { return [ verbosity >= Verbosity.STANDARD && getBeginStackLabel(block), @@ -296,7 +296,7 @@ export function getBeginStackLabel(block: BlockSvg) { export function getInputLabels( block: BlockSvg, verbosity = Verbosity.STANDARD, - fullBlockFieldLabel?: string, + fullBlockFieldLabel: string | undefined = undefined, ): string[] { if (fullBlockFieldLabel) { return [fullBlockFieldLabel]; From 6775966f27954b0c0787635602a8219931195f64 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:42:44 -0400 Subject: [PATCH 4/4] fix: pass label instead of replacing --- packages/blockly/core/field_dropdown.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/field_dropdown.ts b/packages/blockly/core/field_dropdown.ts index 48fa9abd117..932c1af5e67 100644 --- a/packages/blockly/core/field_dropdown.ts +++ b/packages/blockly/core/field_dropdown.ts @@ -944,8 +944,9 @@ export class FieldDropdown extends Field { let label = this.computeAriaLabel(true); if (this.isFullBlockField()) { // Full block fields get a more detailed label that includes the block's label - label = computeAriaLabel(this.getSourceBlock() as BlockSvg).replace( - this.computeAriaLabel(false), + label = computeAriaLabel( + this.getSourceBlock() as BlockSvg, + aria.Verbosity.STANDARD, label, ); }