diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts new file mode 100644 index 00000000000..fa06c489458 --- /dev/null +++ b/packages/blockly/core/block_aria_composer.ts @@ -0,0 +1,317 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from './block_svg.js'; +import {ConnectionType} from './connection_type.js'; +import type {Input} from './inputs/input.js'; +import {inputTypes} from './inputs/input_types.js'; +import { + ISelectableToolboxItem, + isSelectableToolboxItem, +} from './interfaces/i_selectable_toolbox_item.js'; +import {Msg} from './msg.js'; +import {Role, setRole, setState, State, Verbosity} from './utils/aria.js'; + +/** + * Returns an ARIA representation of the specified block. + * + * The returned label will contain a complete context of the block, including: + * - Whether it begins a block stack or statement input stack. + * - Its constituent editable and non-editable fields. + * - Properties, including: disabled, collapsed, replaceable (a shadow), etc. + * - Its parent toolbox category. + * - Whether it has inputs. + * + * Beyond this, the returned label is specifically assembled with commas in + * select locations with the intention of better 'prosody' in the screen reader + * readouts since there's a lot of information being shared with the user. The + * returned label also places more important information earlier in the label so + * that the user gets the most important context as soon as possible in case + * they wish to stop readout early. + * + * The returned label will be specialized based on whether the block is part of a + * flyout. + * + * @internal + * @param block The block for which an ARIA representation should be created. + * @param verbosity How much detail to include in the description. + * @returns The ARIA representation for the specified block. + */ +export function computeAriaLabel( + block: BlockSvg, + verbosity = Verbosity.STANDARD, +) { + return [ + getBeginStackLabel(block), + getParentInputLabel(block), + ...getInputLabels(block), + verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), + verbosity >= Verbosity.STANDARD && getDisabledLabel(block), + verbosity >= Verbosity.STANDARD && getCollapsedLabel(block), + verbosity >= Verbosity.STANDARD && getShadowBlockLabel(block), + verbosity >= Verbosity.STANDARD && getInputCountLabel(block), + ] + .filter((label) => !!label) + .join(', '); +} + +/** + * Sets the ARIA role and role description for the specified block, accounting + * for whether the block is part of a flyout. + * + * @internal + * @param block The block to set ARIA role and roledescription attributes on. + */ +export function configureAriaRole(block: BlockSvg) { + setRole(block.getSvgRoot(), block.isInFlyout ? Role.LISTITEM : Role.FIGURE); + + let roleDescription = Msg['BLOCK_LABEL_STATEMENT']; + if (block.statementInputCount) { + roleDescription = Msg['BLOCK_LABEL_CONTAINER']; + } else if (block.outputConnection) { + roleDescription = Msg['BLOCK_LABEL_VALUE']; + } + + setState(block.getSvgRoot(), State.ROLEDESCRIPTION, roleDescription); +} + +/** + * Returns a list of ARIA labels for the 'field row' for the specified Input. + * + * 'Field row' essentially means the horizontal run of readable fields that + * precede the Input. Together, these provide the domain context for the input, + * particularly in the context of connections. In some cases, there may not be + * any readable fields immediately prior to the Input. In that case, if the + * `lookback` attribute is specified, all of the fields on the row immediately + * above the Input will be used instead. + * + * @internal + * @param input The Input to compute a description/context label for. + * @param lookback If true, will use labels for fields on the previous row if + * the given input's row has no fields itself. + * @returns A list of labels for fields on the same row (or previous row, if + * lookback is specified) as the given input. + */ +export function computeFieldRowLabel( + input: Input, + lookback: boolean, +): string[] { + const fieldRowLabel = input.fieldRow + .filter((field) => field.isVisible()) + .map((field) => field.computeAriaLabel(true)); + if (!fieldRowLabel.length && lookback) { + const inputs = input.getSourceBlock().inputList; + const index = inputs.indexOf(input); + if (index > 0) { + return computeFieldRowLabel(inputs[index - 1], lookback); + } + } + return fieldRowLabel; +} + +/** + * 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. + * + * @internal + * @param block The block to generate a parent input label for. + * @returns A description of the block's parent statement input, or undefined + * for blocks that do not have one. + */ +function getParentInputLabel(block: BlockSvg) { + const parentInput = ( + block.outputConnection ?? block.previousConnection + )?.targetConnection?.getParentInput(); + const parentBlock = parentInput?.getSourceBlock(); + + 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 || parentInput === firstStatementInput) { + return undefined; + } + + const parentInputLabel = computeFieldRowLabel(parentInput, true); + return parentInput.type === inputTypes.STATEMENT + ? Msg['BLOCK_LABEL_BEGIN_PREFIX'].replace('%1', parentInputLabel.join(' ')) + : parentInputLabel; +} + +/** + * Returns text indicating that a block is the root block of a stack. + * + * @internal + * @param block The block to retrieve a label for. + * @returns Text indicating that the block begins a stack, or undefined if it + * does not. + */ +function getBeginStackLabel(block: BlockSvg) { + return !block.workspace.isFlyout && block.getRootBlock() === block + ? Msg['BLOCK_LABEL_BEGIN_STACK'] + : undefined; +} + +/** + * Returns a list of accessibility labels for fields and inputs on a block. + * Each entry in the returned array corresponds to one of: (a) a label for a + * continuous run of non-interactable fields, (b) a label for an editable field, + * (c) a label for an input. When an input contains nested blocks/fields/inputs, + * their contents are returned as a single item in the array per top-level + * input. + * + * @internal + * @param block The block to retrieve a list of field/input labels for. + * @returns A list of field/input labels for the given block. + */ +function getInputLabels(block: BlockSvg): string[] { + return block.inputList + .filter((input) => input.isVisible()) + .flatMap((input) => { + const labels = computeFieldRowLabel(input, false); + + if (input.connection?.type === ConnectionType.INPUT_VALUE) { + const childBlock = input.connection.targetBlock(); + if (childBlock) { + labels.push(getInputLabels(childBlock as BlockSvg).join(' ')); + } + } + + return labels; + }); +} + +/** + * Returns the name of the toolbox category that the given block is part of. + * This is heuristic-based; each toolbox category's contents are enumerated, and + * if a block with the given block's type is encountered, that category is + * deemed to be its parent. As a fallback, a toolbox category with the same + * colour as the block may be returned. This is not comprehensive; blocks may + * exist on the workspace which are not part of any category, or a given block + * type may be part of multiple categories or belong to a dynamically-generated + * category, or there may not even be a toolbox at all. In these cases, either + * the first matching category or undefined will be returned. + * + * This method exists to attempt to provide similar context as block colour + * provides to sighted users, e.g. where a red block comes from a red category. + * It is inherently best-effort due to the above-mentioned constraints. + * + * @internal + * @param block The block to retrieve a category name for. + * @returns A description of the given block's parent toolbox category if any, + * otherwise undefined. + */ +function getParentToolboxCategoryLabel(block: BlockSvg) { + const toolbox = block.workspace.getToolbox(); + if (!toolbox) return undefined; + + let parentCategory: ISelectableToolboxItem | undefined = undefined; + for (const category of toolbox.getToolboxItems()) { + if (!isSelectableToolboxItem(category)) continue; + + const contents = category.getContents(); + if ( + Array.isArray(contents) && + contents.some( + (item) => + item.kind.toLowerCase() === 'block' && + 'type' in item && + item.type === block.type, + ) + ) { + parentCategory = category; + break; + } + + if ( + 'getColour' in category && + typeof category.getColour === 'function' && + category.getColour() === block.getColour() + ) { + parentCategory = category; + } + } + + if (parentCategory) { + return Msg['BLOCK_LABEL_TOOLBOX_CATEGORY'].replace( + '%1', + parentCategory.getName(), + ); + } + + return undefined; +} + +/** + * Returns a label indicating that the block is disabled. + * + * @internal + * @param block The block to generate a label for. + * @returns A label indicating that the block is disabled (if it is), otherwise + * undefined. + */ +export function getDisabledLabel(block: BlockSvg) { + return block.isEnabled() ? undefined : Msg['BLOCK_LABEL_DISABLED']; +} + +/** + * Returns a label indicating that the block is collapsed. + * + * @internal + * @param block The block to generate a label for. + * @returns A label indicating that the block is collapsed (if it is), otherwise + * undefined. + */ +function getCollapsedLabel(block: BlockSvg) { + return block.isCollapsed() ? Msg['BLOCK_LABEL_COLLAPSED'] : undefined; +} + +/** + * Returns a label indicating that the block is a shadow block. + * + * @internal + * @param block The block to generate a label for. + * @returns A label indicating that the block is a shadow (if it is), otherwise + * undefined. + */ +function getShadowBlockLabel(block: BlockSvg) { + return block.isShadow() ? Msg['BLOCK_LABEL_REPLACEABLE'] : undefined; +} + +/** + * Returns a label indicating whether the block has one or multiple inputs. + * + * @internal + * @param block The block to generate a label for. + * @returns A label indicating that the block has one or multiple inputs, + * otherwise undefined. + */ +function getInputCountLabel(block: BlockSvg) { + const inputCount = block.inputList.reduce((totalSum, input) => { + return ( + input.fieldRow.reduce((fieldCount, field) => { + return field.EDITABLE && !field.isFullBlockField() + ? fieldCount++ + : fieldCount; + }, totalSum) + + (input.connection?.type === ConnectionType.INPUT_VALUE ? 1 : 0) + ); + }, 0); + + switch (inputCount) { + case 0: + return undefined; + case 1: + return Msg['BLOCK_LABEL_HAS_INPUT']; + default: + return Msg['BLOCK_LABEL_HAS_INPUTS']; + } +} diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index cf6952a858b..6365a3f7d82 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -16,6 +16,7 @@ import './events/events_selected.js'; import {Block} from './block.js'; import * as blockAnimations from './block_animations.js'; +import {computeAriaLabel, configureAriaRole} from './block_aria_composer.js'; import * as browserEvents from './browser_events.js'; import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js'; import * as common from './common.js'; @@ -62,6 +63,7 @@ import * as blocks from './serialization/blocks.js'; import type {BlockStyle} from './theme.js'; import * as Tooltip from './tooltip.js'; import {idGenerator} from './utils.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import {Rect} from './utils/rect.js'; @@ -244,6 +246,7 @@ export class BlockSvg if (!svg.parentNode) { this.workspace.getCanvas().appendChild(svg); } + this.recomputeAriaAttributes(); this.initialized = true; } @@ -606,6 +609,7 @@ export class BlockSvg this.getInput(collapsedInputName) || this.appendDummyInput(collapsedInputName); input.appendField(new FieldLabel(text), collapsedFieldName); + this.recomputeAriaAttributes(); } /** @@ -842,6 +846,7 @@ export class BlockSvg override setShadow(shadow: boolean) { super.setShadow(shadow); this.applyColour(); + this.recomputeAriaAttributes(); } /** @@ -1062,6 +1067,7 @@ export class BlockSvg for (const child of this.getChildren(false)) { child.updateDisabled(); } + this.recomputeAriaAttributes(); } /** @@ -1885,6 +1891,7 @@ export class BlockSvg /** See IFocusableNode.onNodeFocus. */ onNodeFocus(): void { + this.recomputeAriaAttributes(); this.select(); if (getFocusManager().getFocusedNode() !== this) { renderManagement.finishQueuedRenders().then(() => { @@ -1986,4 +1993,23 @@ export class BlockSvg // All other blocks are their own row. return this.id; } + + /** + * Updates the ARIA label, role and roledescription for this block. + */ + private recomputeAriaAttributes() { + aria.setState(this.getSvgRoot(), aria.State.LABEL, computeAriaLabel(this)); + configureAriaRole(this); + } + + /** + * Returns a description of this block suitable for screenreaders or use in + * ARIA attributes. + * + * @param verbosity How much detail to include in the description. + * @returns An accessibility description of this block. + */ + getAriaLabel(verbosity: aria.Verbosity) { + return computeAriaLabel(this, verbosity); + } } diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 5837adead83..687c6d9c2ea 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -276,6 +276,15 @@ export enum State { VALUEMIN = 'valuemin', } +/** + * Used to control how verbose generated a11y labels are. + */ +export enum Verbosity { + TERSE, + STANDARD, + LOQUACIOUS, +} + /** * Removes the ARIA role from an element. * diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 0716475dc40..980cd43d42b 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-03 10:36:19.846436", + "lastupdated": "2026-04-09 14:28:47.213464", "locale": "en", "messagedocumentation" : "qqq" }, @@ -427,5 +427,16 @@ "WORKSPACE_CONTENTS_COMMENTS_MANY": " and %1 comments", "WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment", "KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Use the right arrow key to navigate inside of blocks", - "KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Use the arrow keys to navigate" + "KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Use the arrow keys to navigate", + "BLOCK_LABEL_BEGIN_STACK": "Begin stack", + "BLOCK_LABEL_BEGIN_PREFIX": "Begin %1", + "BLOCK_LABEL_TOOLBOX_CATEGORY": "%1 category", + "BLOCK_LABEL_DISABLED": "disabled", + "BLOCK_LABEL_COLLAPSED": "collapsed", + "BLOCK_LABEL_REPLACEABLE": "replaceable", + "BLOCK_LABEL_HAS_INPUT": "has input", + "BLOCK_LABEL_HAS_INPUTS": "has inputs", + "BLOCK_LABEL_STATEMENT": "statement", + "BLOCK_LABEL_CONTAINER": "container", + "BLOCK_LABEL_VALUE": "value" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 6392bd68341..e42f1ff5ec4 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -434,5 +434,16 @@ "WORKSPACE_CONTENTS_COMMENTS_MANY": "ARIA live region phrase appended when there are multiple workspace comments. \n\nParameters:\n* %1 - the number of comments (integer greater than 1)", "WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment.", "KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Message shown when a user presses Enter with a navigable block focused.", - "KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Message shown when a user presses Enter with the workspace focused." + "KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Message shown when a user presses Enter with the workspace focused.", + "BLOCK_LABEL_BEGIN_STACK": "Part of an accessibility label for a block that indicates it is the first block in the stack.", + "BLOCK_LABEL_BEGIN_PREFIX": "Part of an accessibility label for a block that indicates it is the first block inside of a statement input. Placeholder corresponds to the parent statement input's accessibility label.", + "BLOCK_LABEL_TOOLBOX_CATEGORY": "Part of an accessibility label for a block that indicates its parent toolbox category. Placeholder corresponds to a category name, e.g. 'Logic' or 'Math'.", + "BLOCK_LABEL_DISABLED": "Part of an accessibility label for a block that indicates that it is disabled.", + "BLOCK_LABEL_COLLAPSED": "Part of an accessibility label for a block that indicates that it is collapsed.", + "BLOCK_LABEL_REPLACEABLE": "Part of an accessibility label for a block that indicates that it is replaceable, i.e. that it is a shadow block.", + "BLOCK_LABEL_HAS_INPUT": "Part of an accessibility label for a block that indicates that it has a single input.", + "BLOCK_LABEL_HAS_INPUTS": "Part of an accessibility label for a block that indicates that it has more than one input.", + "BLOCK_LABEL_STATEMENT": "Part of an accessibility label for a block that indicates that it is a statement block, i.e. that it has a next or previous connection.", + "BLOCK_LABEL_CONTAINER": "Part of an accessibility label for a block that indicates that it is a container block, i.e. that it has one or more statement inputs.", + "BLOCK_LABEL_VALUE": "Part of an accessibility label for a block that indicates that it is a value block, i.e. that it has an output connection." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index bf36a43c4bf..e9f7dfa9a6f 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1724,3 +1724,49 @@ Blockly.Msg.KEYBOARD_NAV_BLOCK_NAVIGATION_HINT = 'Use the right arrow key to nav /** @type {string} */ /// Message shown when a user presses Enter with the workspace focused. Blockly.Msg.KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT = 'Use the arrow keys to navigate'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates it is the first +/// block in the stack. +Blockly.Msg.BLOCK_LABEL_BEGIN_STACK = 'Begin stack'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates it is the first +/// block inside of a statement input. Placeholder corresponds to the parent +/// statement input's accessibility label. +Blockly.Msg.BLOCK_LABEL_BEGIN_PREFIX = 'Begin %1'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates its parent toolbox +/// category. Placeholder corresponds to a category name, e.g. "Logic" or +/// "Math". +Blockly.Msg.BLOCK_LABEL_TOOLBOX_CATEGORY = '%1 category'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// disabled. +Blockly.Msg.BLOCK_LABEL_DISABLED = 'disabled'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// collapsed. +Blockly.Msg.BLOCK_LABEL_COLLAPSED = 'collapsed'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// replaceable, i.e. that it is a shadow block. +Blockly.Msg.BLOCK_LABEL_REPLACEABLE = 'replaceable'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it has a +/// single input. +Blockly.Msg.BLOCK_LABEL_HAS_INPUT = 'has input'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it has more +/// than one input. +Blockly.Msg.BLOCK_LABEL_HAS_INPUTS = 'has inputs'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// a statement block, i.e. that it has a next or previous connection. +Blockly.Msg.BLOCK_LABEL_STATEMENT = 'statement'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// a container block, i.e. that it has one or more statement inputs. +Blockly.Msg.BLOCK_LABEL_CONTAINER = 'container'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// a value block, i.e. that it has an output connection. +Blockly.Msg.BLOCK_LABEL_VALUE = 'value'; diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 806b5d7c0d0..3fc959753e4 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -10,10 +10,24 @@ import { sharedTestTeardown, } from './test_helpers/setup_teardown.js'; -suite('Aria', function () { +suite('ARIA', function () { setup(function () { sharedTestSetup.call(this); - this.workspace = Blockly.inject('blocklyDiv', {}); + Blockly.defineBlocksWithJsonArray([ + { + type: 'basic_block', + message0: '%1', + args0: [ + { + type: 'field_input', + name: 'TEXT', + text: 'default', + }, + ], + }, + ]); + const toolbox = document.getElementById('toolbox-categories'); + this.workspace = Blockly.inject('blocklyDiv', {toolbox}); this.liveRegion = document.getElementById('blocklyAriaAnnounce'); }); @@ -263,4 +277,177 @@ suite('Aria', function () { assert.equal(element.getAttribute('aria-label'), 'one two three'); }); }); + + suite('Blocks', function () { + setup(function () { + this.makeBlock = (blockType) => { + const block = this.workspace.newBlock(blockType); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block); + return block; + }; + }); + + test('Statement blocks have correct role description', function () { + const block = this.makeBlock('text_print'); + const roleDescription = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.ROLEDESCRIPTION, + ); + assert.equal(roleDescription, 'statement'); + }); + + test('Value blocks have correct role description', function () { + const block = this.makeBlock('logic_boolean'); + const roleDescription = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.ROLEDESCRIPTION, + ); + assert.equal(roleDescription, 'value'); + }); + + test('Container blocks have correct role description', function () { + const block = this.makeBlock('controls_if'); + const roleDescription = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.ROLEDESCRIPTION, + ); + assert.equal(roleDescription, 'container'); + }); + + test('Workspace blocks have the correct role', function () { + const block = this.makeBlock('text_print'); + const role = Blockly.utils.aria.getRole(block.getSvgRoot()); + assert.equal(role, Blockly.utils.aria.Role.FIGURE); + }); + + test('Flyout blocks have the correct role', function () { + Blockly.getFocusManager().focusNode( + this.workspace.getToolbox().getToolboxItems()[0], + ); + const block = this.workspace.getFlyout().getWorkspace().getTopBlocks()[0]; + const role = Blockly.utils.aria.getRole(block.getSvgRoot()); + assert.equal(role, Blockly.utils.aria.Role.LISTITEM); + }); + + test('Root workspace blocks indicate that in their labels', function () { + const block = this.makeBlock('text_print'); + const label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isTrue(label.startsWith('Begin stack')); + }); + + test('Flyout blocks are not labeled as beginning a stack', function () { + Blockly.getFocusManager().focusNode( + this.workspace.getToolbox().getToolboxItems()[0], + ); + const block = this.workspace.getFlyout().getWorkspace().getTopBlocks()[0]; + const label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'Begin stack'); + }); + + test('Nested 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); + const label = Blockly.utils.aria.getState( + printBlock.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isFalse(label.startsWith('Begin do')); + }); + + test('Nested 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 + .getInput('ELSE') + .connection.connect(printBlock.previousConnection); + const label = Blockly.utils.aria.getState( + printBlock.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isTrue(label.startsWith('Begin else')); + }); + + test('Disabled blocks indicate that in their label', function () { + const block = this.makeBlock('text_print'); + let label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'disabled'); + block.setDisabledReason(true, 'testing'); + label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.include(label, 'disabled'); + }); + + test('Collapsed blocks indicate that in their label', function () { + const block = this.makeBlock('text_print'); + let label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'collapsed'); + block.setCollapsed(true); + label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.include(label, 'collapsed'); + }); + + test('Shadow blocks indicate that in their label', function () { + const block = this.makeBlock('text_print'); + const text = this.makeBlock('text'); + text.outputConnection.connect(block.inputList[0].connection); + let label = Blockly.utils.aria.getState( + text.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'replaceable'); + text.setShadow(true); + label = Blockly.utils.aria.getState( + text.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.include(label, 'replaceable'); + }); + + test('Blocks without inputs are properly labeled', function () { + const block = this.makeBlock('math_random_float'); + const label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'input'); + }); + + test('Blocks with one input are properly labeled', function () { + const block = this.makeBlock('logic_negate'); + const label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isTrue(label.endsWith('has input')); + }); + + test('Blocks with multiple inputs are properly labeled', function () { + const block = this.makeBlock('logic_ternary'); + const label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isTrue(label.endsWith('has inputs')); + }); + }); });