diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index e39de955127..be433f1b730 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -57,23 +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 | undefined = undefined, ) { - 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), - ...getInputLabels(block, verbosity), + ...getInputLabels(block, verbosity, fullBlockFieldLabel), verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), verbosity >= Verbosity.STANDARD && getDisabledLabel(block), verbosity >= Verbosity.STANDARD && getCollapsedLabel(block), @@ -271,7 +266,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; @@ -295,12 +290,17 @@ 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 | undefined = undefined, ): 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_dropdown.ts b/packages/blockly/core/field_dropdown.ts index 11e3f6282a8..932c1af5e67 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,15 @@ 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, + aria.Verbosity.STANDARD, + 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..3c9db623cb6 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'; @@ -32,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'; @@ -855,8 +857,37 @@ 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, + Verbosity.STANDARD, + 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..e08b2202f60 100644 --- a/packages/blockly/tests/mocha/field_dropdown_test.js +++ b/packages/blockly/tests/mocha/field_dropdown_test.js @@ -580,5 +580,98 @@ 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'); + }); + 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 + .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('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, + ); + + 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), + ); + 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 59d82b4b141..612066b786f 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -551,5 +551,94 @@ 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(); + }); + 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() + .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('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, + ); + 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), + ); + 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'); + }); + }); }); });