feat: Add basic support for generating ARIA labels and roles for blocks (#9696)

* feat: Add basic support for generating ARIA labels and roles for blocks

* test: Add tests

* chore: Fix lint

* chore: Revert tooling removal of authors

* chore: Adjust casing of method name

* chore: Tweak name of verbosity enum value

* chore: Adjust name of shadow block label method

* chore: Add trailing newline

* chore: Fix method casing

* feat: Add method to retrieve a block's ARIA label

* fix: Fix TSDoc

* chore: Adjust method casing
This commit is contained in:
Aaron Dodson
2026-04-15 08:12:08 -07:00
committed by GitHub
parent dc2afe3527
commit 91d02eee02
7 changed files with 612 additions and 5 deletions
@@ -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'];
}
}
+26
View File
@@ -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);
}
}
+9
View File
@@ -216,6 +216,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.
*
+13 -2
View File
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"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"
}
+12 -1
View File
@@ -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."
}
+46
View File
@@ -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';
+189 -2
View File
@@ -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'));
});
});
});