From 7454ffd953019f4885e30a4568347b7ee810936e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 10 Apr 2026 08:32:55 -0700 Subject: [PATCH 01/10] feat: Add basic support for generating ARIA labels and roles for blocks --- packages/blockly/core/block_aria_composer.ts | 319 +++++++++++++++++++ packages/blockly/core/block_svg.ts | 15 + packages/blockly/core/utils/aria.ts | 9 + packages/blockly/msg/json/en.json | 15 +- packages/blockly/msg/json/qqq.json | 26 +- packages/blockly/msg/messages.js | 46 +++ 6 files changed, 414 insertions(+), 16 deletions(-) create mode 100644 packages/blockly/core/block_aria_composer.ts diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts new file mode 100644 index 00000000000..1690ce53eae --- /dev/null +++ b/packages/blockly/core/block_aria_composer.ts @@ -0,0 +1,319 @@ +/** + * @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.NORMAL, +) { + return [ + getBeginStackLabel(block), + getParentInputLabel(block), + ...getInputLabels(block), + verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), + verbosity >= Verbosity.NORMAL && getDisabledLabel(block), + verbosity >= Verbosity.NORMAL && getCollapsedLabel(block), + verbosity >= Verbosity.NORMAL && getReplaceableLabel(block), + verbosity >= Verbosity.NORMAL && 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 an ARIA representation of 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. + * + * Returns undefined if no fields precede the given Input. + * + * @internal + * @param input The Input to compute a description/context label for. + * @param lookback If true, will use fields on the previous row to compute a + * label for the given input if it has no fields itself. + * @returns An accessibility label for the given input, or undefined if one + * cannot be computed. + */ +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 getReplaceableLabel(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..a6b3fcd0cf9 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,12 @@ 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); + } } diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 5837adead83..c596cb689ca 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, + NORMAL, + 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..cdc33a33cd0 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -1,17 +1,4 @@ { - "@metadata": { - "authors": [ - "Ajeje Brazorf", - "Amire80", - "Espertus", - "Liuxinyu970226", - "McDutchie", - "Metalhead64", - "Nike", - "Robby", - "Shirayuki" - ] - }, "VARIABLES_DEFAULT_NAME": "default name - A simple, general default name for a variable, preferably short. For more context, see [[Translating:Blockly#infrequent_message_types]].\n{{Identical|Item}}", "UNNAMED_KEY": "default name - A simple, default name for an unnamed function or variable. Preferably indicates that the item is unnamed.", "TODAY": "button text - Button that sets a calendar to today's date.\n{{Identical|Today}}", @@ -434,5 +421,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..83f61caab6c 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'; \ No newline at end of file From 7da0556c6195ce72843ea28d0e063a9e8bb84e80 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 10 Apr 2026 08:33:02 -0700 Subject: [PATCH 02/10] test: Add tests --- packages/blockly/tests/mocha/aria_test.js | 191 +++++++++++++++++++++- 1 file changed, 189 insertions(+), 2 deletions(-) diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 806b5d7c0d0..63af7a7269d 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'); + let 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'); + let 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'); + let label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isTrue(label.endsWith('has inputs')); + }); + }); }); From bd7fd5c0b762d005f0117e64cacd194f11cf6166 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 10 Apr 2026 08:56:37 -0700 Subject: [PATCH 03/10] chore: Fix lint --- packages/blockly/tests/mocha/aria_test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 63af7a7269d..3fc959753e4 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -425,7 +425,7 @@ suite('ARIA', function () { test('Blocks without inputs are properly labeled', function () { const block = this.makeBlock('math_random_float'); - let label = Blockly.utils.aria.getState( + const label = Blockly.utils.aria.getState( block.getSvgRoot(), Blockly.utils.aria.State.LABEL, ); @@ -434,7 +434,7 @@ suite('ARIA', function () { test('Blocks with one input are properly labeled', function () { const block = this.makeBlock('logic_negate'); - let label = Blockly.utils.aria.getState( + const label = Blockly.utils.aria.getState( block.getSvgRoot(), Blockly.utils.aria.State.LABEL, ); @@ -443,7 +443,7 @@ suite('ARIA', function () { test('Blocks with multiple inputs are properly labeled', function () { const block = this.makeBlock('logic_ternary'); - let label = Blockly.utils.aria.getState( + const label = Blockly.utils.aria.getState( block.getSvgRoot(), Blockly.utils.aria.State.LABEL, ); From c282b2bfd2500cb0abecde4edd427291810e23d1 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 10 Apr 2026 08:58:29 -0700 Subject: [PATCH 04/10] chore: Revert tooling removal of authors --- packages/blockly/msg/json/qqq.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index cdc33a33cd0..e42f1ff5ec4 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -1,4 +1,17 @@ { + "@metadata": { + "authors": [ + "Ajeje Brazorf", + "Amire80", + "Espertus", + "Liuxinyu970226", + "McDutchie", + "Metalhead64", + "Nike", + "Robby", + "Shirayuki" + ] + }, "VARIABLES_DEFAULT_NAME": "default name - A simple, general default name for a variable, preferably short. For more context, see [[Translating:Blockly#infrequent_message_types]].\n{{Identical|Item}}", "UNNAMED_KEY": "default name - A simple, default name for an unnamed function or variable. Preferably indicates that the item is unnamed.", "TODAY": "button text - Button that sets a calendar to today's date.\n{{Identical|Today}}", From 0f80a69e246c95706c21fcca090bfcf6fbbd7747 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 10 Apr 2026 14:20:55 -0700 Subject: [PATCH 05/10] chore: Adjust casing of method name --- packages/blockly/core/block_aria_composer.ts | 2 +- packages/blockly/core/block_svg.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 1690ce53eae..799baa86f79 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -40,7 +40,7 @@ import {Role, setRole, setState, State, Verbosity} from './utils/aria.js'; * @param verbosity How much detail to include in the description. * @returns The ARIA representation for the specified block. */ -export function computeARIALabel( +export function computeAriaLabel( block: BlockSvg, verbosity = Verbosity.NORMAL, ) { diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index a6b3fcd0cf9..3eb7fbbd511 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -16,7 +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 {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'; @@ -1998,7 +1998,7 @@ export class BlockSvg * Updates the ARIA label, role and roledescription for this block. */ private recomputeARIAAttributes() { - aria.setState(this.getSvgRoot(), aria.State.LABEL, computeARIALabel(this)); + aria.setState(this.getSvgRoot(), aria.State.LABEL, computeAriaLabel(this)); configureARIARole(this); } } From 1df8ae34914c849c4e73b46e0907ea214375b597 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 10 Apr 2026 14:21:51 -0700 Subject: [PATCH 06/10] chore: Tweak name of verbosity enum value --- packages/blockly/core/block_aria_composer.ts | 10 +++++----- packages/blockly/core/utils/aria.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 799baa86f79..e4c1c43f1cb 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -42,17 +42,17 @@ import {Role, setRole, setState, State, Verbosity} from './utils/aria.js'; */ export function computeAriaLabel( block: BlockSvg, - verbosity = Verbosity.NORMAL, + verbosity = Verbosity.STANDARD, ) { return [ getBeginStackLabel(block), getParentInputLabel(block), ...getInputLabels(block), verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), - verbosity >= Verbosity.NORMAL && getDisabledLabel(block), - verbosity >= Verbosity.NORMAL && getCollapsedLabel(block), - verbosity >= Verbosity.NORMAL && getReplaceableLabel(block), - verbosity >= Verbosity.NORMAL && getInputCountLabel(block), + verbosity >= Verbosity.STANDARD && getDisabledLabel(block), + verbosity >= Verbosity.STANDARD && getCollapsedLabel(block), + verbosity >= Verbosity.STANDARD && getReplaceableLabel(block), + verbosity >= Verbosity.STANDARD && getInputCountLabel(block), ] .filter((label) => !!label) .join(', '); diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index c596cb689ca..687c6d9c2ea 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -281,7 +281,7 @@ export enum State { */ export enum Verbosity { TERSE, - NORMAL, + STANDARD, LOQUACIOUS, } From 02fc184e0afeb4a8359f9b53c6285bda856bfbf9 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 10 Apr 2026 14:25:40 -0700 Subject: [PATCH 07/10] chore: Adjust name of shadow block label method --- 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 e4c1c43f1cb..37786c525d1 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -51,7 +51,7 @@ export function computeAriaLabel( verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), verbosity >= Verbosity.STANDARD && getDisabledLabel(block), verbosity >= Verbosity.STANDARD && getCollapsedLabel(block), - verbosity >= Verbosity.STANDARD && getReplaceableLabel(block), + verbosity >= Verbosity.STANDARD && getShadowBlockLabel(block), verbosity >= Verbosity.STANDARD && getInputCountLabel(block), ] .filter((label) => !!label) @@ -284,7 +284,7 @@ function getCollapsedLabel(block: BlockSvg) { * @returns A label indicating that the block is a shadow (if it is), otherwise * undefined. */ -function getReplaceableLabel(block: BlockSvg) { +function getShadowBlockLabel(block: BlockSvg) { return block.isShadow() ? Msg['BLOCK_LABEL_REPLACEABLE'] : undefined; } From 71978318a1801d3079d68eea03c550d0ea30931c Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 10 Apr 2026 14:52:37 -0700 Subject: [PATCH 08/10] chore: Add trailing newline --- packages/blockly/msg/messages.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 83f61caab6c..e9f7dfa9a6f 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1769,4 +1769,4 @@ 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'; \ No newline at end of file +Blockly.Msg.BLOCK_LABEL_VALUE = 'value'; From 3a9ebc380f307b6e12e36909e301da97f2b55536 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 10 Apr 2026 15:07:33 -0700 Subject: [PATCH 09/10] chore: Fix method casing --- packages/blockly/core/block_aria_composer.ts | 2 +- packages/blockly/core/block_svg.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 37786c525d1..22c020b3527 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -65,7 +65,7 @@ export function computeAriaLabel( * @internal * @param block The block to set ARIA role and roledescription attributes on. */ -export function configureARIARole(block: BlockSvg) { +export function configureAriaRole(block: BlockSvg) { setRole(block.getSvgRoot(), block.isInFlyout ? Role.LISTITEM : Role.FIGURE); let roleDescription = Msg['BLOCK_LABEL_STATEMENT']; diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 3eb7fbbd511..c9762de9b56 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -16,7 +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 {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'; @@ -1999,6 +1999,6 @@ export class BlockSvg */ private recomputeARIAAttributes() { aria.setState(this.getSvgRoot(), aria.State.LABEL, computeAriaLabel(this)); - configureARIARole(this); + configureAriaRole(this); } } From 3547644e50a2f19cf08c19879196cef8b2270a27 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 14 Apr 2026 09:43:21 -0700 Subject: [PATCH 10/10] feat: Add method to retrieve a block's ARIA label --- packages/blockly/core/block_svg.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index c9762de9b56..14193bab867 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -2001,4 +2001,15 @@ export class BlockSvg 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); + } }