diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index e39de955127..3f53cdb053e 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -6,7 +6,6 @@ import type {BlockSvg} from './block_svg.js'; import {ConnectionType} from './connection_type.js'; -import {FieldLabel} from './field_label.js'; import type {Input} from './inputs/input.js'; import {inputTypes} from './inputs/input_types.js'; import { @@ -57,23 +56,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), @@ -143,11 +137,11 @@ export function computeFieldRowLabel( const fieldRowLabel = input.fieldRow .filter((field) => field.isVisible()) .flatMap((field, index, visibleFields) => { - const isFieldLabel = field instanceof FieldLabel; + const isFieldLabel = field.isLabelField(); if (isFieldLabel) { if ( index < visibleFields.length - 1 && - visibleFields[index + 1] instanceof FieldLabel + visibleFields[index + 1].isLabelField() ) { // Both this item and the next item are FieldLabels. We want to // combine these, so we add this one to the list for later handling. @@ -271,7 +265,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 +289,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; @@ -354,7 +353,7 @@ export function getInputLabels( */ function beginsWithFieldLabel(input: Input): boolean { const visibleFields = input.fieldRow.filter((field) => field.isVisible()); - return visibleFields.length > 0 && visibleFields[0] instanceof FieldLabel; + return visibleFields.length > 0 && visibleFields[0].isLabelField(); } /** @@ -372,7 +371,7 @@ function endsWithFieldLabel(input: Input): boolean { const visibleFields = input.fieldRow.filter((field) => field.isVisible()); return ( visibleFields.length > 0 && - visibleFields[visibleFields.length - 1] instanceof FieldLabel + visibleFields[visibleFields.length - 1].isLabelField() ); } 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..8d14a539147 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -17,6 +17,7 @@ import './events/events_block_change.js'; import type {Block} from './block.js'; +import {computeAriaLabel} from './block_aria_composer.js'; import type {BlockSvg} from './block_svg.js'; import * as browserEvents from './browser_events.js'; import * as dropDownDiv from './dropdowndiv.js'; @@ -458,6 +459,16 @@ export abstract class Field return false; } + /** + * Returns whether this field is a static text label (ex. FieldLabel). + * Used internally instead of `instanceof FieldLabel` to avoid circular imports. + * + * @internal + */ + isLabelField(): boolean { + return false; + } + /** * Create a field border rect element. Not to be overridden by subclasses. * Instead modify the result of the function inside initView, or create a @@ -1518,7 +1529,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(); @@ -1560,7 +1571,16 @@ export abstract class Field // editing mode that can be activated. aria.setRole(focusableElement, aria.Role.BUTTON); - 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); return true; } diff --git a/packages/blockly/core/field_dropdown.ts b/packages/blockly/core/field_dropdown.ts index 11e3f6282a8..7d58439236b 100644 --- a/packages/blockly/core/field_dropdown.ts +++ b/packages/blockly/core/field_dropdown.ts @@ -933,16 +933,13 @@ export class FieldDropdown extends Field { } /** - * Overrides the default label and sets additional aria state. + * Sets additional aria state. */ override recomputeAriaContext(): boolean { const shouldCustomize = super.recomputeAriaContext(); if (!shouldCustomize) return false; const focusableElement = this.getFocusableElement(); - const label = this.computeAriaLabel(true); - - aria.setState(focusableElement, aria.State.LABEL, label); aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox'); aria.setState(focusableElement, aria.State.EXPANDED, !!this.menu_); return true; 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/core/field_label.ts b/packages/blockly/core/field_label.ts index 49f0583d216..906712390dd 100644 --- a/packages/blockly/core/field_label.ts +++ b/packages/blockly/core/field_label.ts @@ -34,6 +34,10 @@ export class FieldLabel extends Field { /** Text labels should not truncate. */ override maxDisplayLength = Infinity; + override isLabelField(): boolean { + return true; + } + /** * @param value The initial value of the field. Should cast to a string. * Defaults to an empty string if null or undefined. Also accepts 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'); + }); + }); }); });