feat: Screenreader announcements for move mode (#9731)

* feat: Screenreader announcements for move mode

* fix: lint

* fix: update docstrings

* fix: code review changes

* fix: add block id to error
This commit is contained in:
Michael Harvey
2026-04-20 13:12:02 -04:00
committed by GitHub
parent 2903ec9f3a
commit 16767eaaa2
10 changed files with 682 additions and 61 deletions
+198 -13
View File
@@ -5,6 +5,7 @@
*/
import type {BlockSvg} from './block_svg.js';
import {RenderedConnection} from './blockly.js';
import {ConnectionType} from './connection_type.js';
import type {Input} from './inputs/input.js';
import {inputTypes} from './inputs/input_types.js';
@@ -15,6 +16,18 @@ import {
import {Msg} from './msg.js';
import {Role, setRole, setState, State, Verbosity} from './utils/aria.js';
/**
* Prepositions to use when describing the relationship between two blocks based
* on their connection types.
*/
export enum ConnectionPreposition {
UNKNOWN,
BEFORE,
AFTER,
AROUND,
INSIDE,
}
/**
* Returns an ARIA representation of the specified block.
*
@@ -45,7 +58,7 @@ export function computeAriaLabel(
verbosity = Verbosity.STANDARD,
) {
return [
getBeginStackLabel(block),
verbosity >= Verbosity.STANDARD && getBeginStackLabel(block),
getParentInputLabel(block),
...getInputLabels(block),
verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block),
@@ -129,6 +142,7 @@ function getParentInputLabel(block: BlockSvg) {
)?.targetConnection?.getParentInput();
const parentBlock = parentInput?.getSourceBlock();
if (parentBlock?.isInsertionMarker()) return undefined;
if (!parentBlock?.statementInputCount) return undefined;
const firstStatementInput = parentBlock.inputList.find(
@@ -172,21 +186,76 @@ function getBeginStackLabel(block: BlockSvg) {
* @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[] {
export function getInputLabels(block: BlockSvg): string[] {
return block.inputList
.filter((input) => input.isVisible())
.flatMap((input) => {
const labels = computeFieldRowLabel(input, false);
.map((input) => input.getLabel());
}
if (input.connection?.type === ConnectionType.INPUT_VALUE) {
const childBlock = input.connection.targetBlock();
if (childBlock) {
labels.push(getInputLabels(childBlock as BlockSvg).join(' '));
}
}
/**
* Returns a subset of labels for inputs on the given block, ending at the
* specified input.
*
* The subset is determined based on the input type:
* - For non-statement inputs, only the label for the given input is returned.
* - For statement inputs, labels are collected from the start of the current
* statement section up to and including the given input. A statement section
* begins immediately after the previous statement input, or at the start of
* the block if none exists.
*
* @internal
* @param block The block to retrieve a list of field/input labels for.
* @param input The input that defines the end of the subset.
* @returns A list of field/input labels for the given block.
*/
export function getInputLabelsSubset(block: BlockSvg, input: Input): string[] {
const inputIndex = block.inputList.indexOf(input);
if (inputIndex === -1) {
throw new Error(
`Input with name "${input.name}" not found on block with id "${block.id}".`,
);
}
return labels;
});
const startIndex =
input.type === inputTypes.STATEMENT
? findStartOfStatementSection(block.inputList, inputIndex)
: inputIndex;
return block.inputList
.slice(startIndex, inputIndex + 1)
.filter((input) => input.isVisible())
.map(
(input) =>
input.getLabel() ||
Msg['INPUT_LABEL_INDEX'].replace(
'%1',
(input.getIndex() + 1).toString(),
),
);
}
/**
* Finds the starting index of the current statement section within a list of inputs.
*
* A statement section is defined as the group of inputs that follow the most
* recent preceding statement input. If no prior statement input exists, the
* section starts at index 0.
*
* @param inputs The list of inputs to search.
* @param fromIndex The index of the current statement input.
* @returns The index of the first input in the current statement section.
*/
function findStartOfStatementSection(
inputs: Input[],
fromIndex: number,
): number {
// Find the first input after the previous statement input.
for (let i = fromIndex - 1; i >= 0; i--) {
if (inputs[i].type === inputTypes.STATEMENT) {
return i + 1;
}
}
return 0;
}
/**
@@ -250,6 +319,122 @@ function getParentToolboxCategoryLabel(block: BlockSvg) {
return undefined;
}
/**
* Returns a translated string describing an in-progress move of a block to a new
* connection, suitable for announcement on the ARIA live region. The returned string
* will be assembled based on the types of the local and neighbour connections and
* the presence of any readable fields on the block's inputs. If multiple potential
* candidate connections are present, additional context will be included in the
* returned string to help disambiguate between them.
*
* @param local The moving side of the candidate connection pair
* @param neighbour The target side of the candidate connection pair
* @param disambiguationPolicy A function that determines whether it's useful to
* include parent input labels for disambiguation.
* @param isMoveStart Whether this announcement is for the start of a move. If false,
* skip announcing the block label since it should have already been announced.
*/
export function computeMoveLabel(
local: RenderedConnection,
neighbour: RenderedConnection,
disambiguationPolicy: (forLocal: boolean) => boolean,
isMoveStart = false,
): string {
const preposition = getConnectionPreposition(local, neighbour);
const neighbourBlock = neighbour.getSourceBlock() as BlockSvg;
const neighbourBlockLabel = neighbourBlock.getAriaLabel(Verbosity.TERSE);
const blockLabel = isMoveStart
? local.getSourceBlock().getStackBlocksCountLabel()
: '';
let announcementTemplate;
// Message strings take a format like 'moving %1 %2 to %3 %4' where:
// "to" is replaced with a preposition based on the type of the connection candidate
// (e.g. "before", "after", "inside", "around", etc), and the placeholders are replaced with:
// %1 = optional label for the block being moved
// %2 = optional label for the local connection
// %3 = label for the neighbour block
// %4 = optional label for the neighbour connection
switch (preposition) {
case ConnectionPreposition.BEFORE:
announcementTemplate = Msg['ANNOUNCE_MOVE_BEFORE'];
break;
case ConnectionPreposition.AFTER:
announcementTemplate = Msg['ANNOUNCE_MOVE_AFTER'];
break;
case ConnectionPreposition.INSIDE:
announcementTemplate = Msg['ANNOUNCE_MOVE_INSIDE'];
break;
case ConnectionPreposition.AROUND:
announcementTemplate = Msg['ANNOUNCE_MOVE_AROUND'];
break;
case ConnectionPreposition.UNKNOWN:
announcementTemplate = Msg['ANNOUNCE_MOVE_UNKNOWN'];
}
// If multiple compatible candidate connections exist for either/both pairs of the
// current connection candidate, increase the verbosity of the announcement to help
// disambiguate them.
const requiresDisambiguation = [
ConnectionPreposition.INSIDE,
ConnectionPreposition.AROUND,
].includes(preposition);
const describeLocal = requiresDisambiguation && disambiguationPolicy(true);
const describeNeighbour =
requiresDisambiguation && disambiguationPolicy(false);
const localInput = local.getParentInput();
const neighbourInput = neighbour.getParentInput();
const localConnLabel =
(describeLocal &&
localInput &&
getInputLabelsSubset(local.getSourceBlock(), localInput).join(', ')) ||
'';
const neighbourConnLabel =
(describeNeighbour &&
neighbourInput &&
getInputLabelsSubset(neighbourBlock, neighbourInput).join(', ')) ||
'';
return announcementTemplate
.replace('%1', blockLabel)
.replace('%2', localConnLabel)
.replace('%3', neighbourBlockLabel)
.replace('%4', neighbourConnLabel);
}
/**
* Returns the appropriate preposition to use in the move announcement based on the
* relationship between the local and neighbour connections.
*/
function getConnectionPreposition(
local: RenderedConnection,
neighbour: RenderedConnection,
): ConnectionPreposition {
switch (local.type) {
case ConnectionType.OUTPUT_VALUE:
return ConnectionPreposition.INSIDE;
case ConnectionType.INPUT_VALUE:
return ConnectionPreposition.AROUND;
case ConnectionType.NEXT_STATEMENT:
if (local === local.getSourceBlock().nextConnection) {
return ConnectionPreposition.BEFORE;
} else {
return ConnectionPreposition.AROUND;
}
case ConnectionType.PREVIOUS_STATEMENT:
if (neighbour === neighbour.getSourceBlock().nextConnection) {
return ConnectionPreposition.AFTER;
} else {
return ConnectionPreposition.INSIDE;
}
}
// Not normally reachable since we should always have a connection candidate
// with valid connection types. Satisfies the return type.
return ConnectionPreposition.UNKNOWN;
}
/**
* Returns a label indicating that the block is disabled.
*
@@ -258,7 +443,7 @@ function getParentToolboxCategoryLabel(block: BlockSvg) {
* @returns A label indicating that the block is disabled (if it is), otherwise
* undefined.
*/
export function getDisabledLabel(block: BlockSvg) {
function getDisabledLabel(block: BlockSvg) {
return block.isEnabled() ? undefined : Msg['BLOCK_LABEL_DISABLED'];
}
+21
View File
@@ -2012,4 +2012,25 @@ export class BlockSvg
getAriaLabel(verbosity: aria.Verbosity) {
return computeAriaLabel(this, verbosity);
}
/**
* Count the number of blocks in this stack (connected by next connections)
* and return a label to describe it. Uses the standard label if there is only one block.
*
* @internal
*/
getStackBlocksCountLabel(): string {
let count = 1;
let block = this.getNextBlock();
while (block) {
count++;
block = block.getNextBlock();
}
if (count <= 1) {
return this.getAriaLabel(aria.Verbosity.TERSE);
}
const labelTemplate = Msg['BLOCK_LABEL_STACK_BLOCKS'];
return labelTemplate.replace('%1', count.toString());
}
}
@@ -6,6 +6,7 @@
import type {Block} from '../block.js';
import * as blockAnimation from '../block_animations.js';
import {computeMoveLabel} from '../block_aria_composer.js';
import type {BlockSvg} from '../block_svg.js';
import * as bumpObjects from '../bump_objects.js';
import {config} from '../config.js';
@@ -22,11 +23,13 @@ import {DragDisposition} from '../interfaces/i_draggable.js';
import {IHasBubble, hasBubble} from '../interfaces/i_has_bubble.js';
import {Direction} from '../keyboard_nav/keyboard_mover.js';
import * as layers from '../layers.js';
import {Msg} from '../msg.js';
import * as registry from '../registry.js';
import {finishQueuedRenders} from '../render_management.js';
import type {RenderedConnection} from '../rendered_connection.js';
import * as blocks from '../serialization/blocks.js';
import {Coordinate} from '../utils.js';
import * as aria from '../utils/aria.js';
import * as dom from '../utils/dom.js';
import * as svgMath from '../utils/svg_math.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
@@ -153,6 +156,58 @@ export class BlockDragStrategy implements IDragStrategy {
return this.block;
}
/**
* Announces a move on the ARIA live region for assistive technologies.
*
* @param isMoveStart Whether this announcement is for the start of a move. If false,
* skip announcing the block label since it should have already been announced at the
* start of the move.
*/
private announceMove(isMoveStart: boolean = false) {
let announcementTemplate = '';
let announcement = '';
if (this.connectionCandidate) {
announcement = computeMoveLabel(
this.connectionCandidate.local,
this.connectionCandidate.neighbour,
this.hasMultipleCompatibleConnections.bind(this),
isMoveStart,
);
} else {
const blockLabel = isMoveStart
? this.block.getStackBlocksCountLabel()
: '';
announcementTemplate = Msg['ANNOUNCE_MOVE_WORKSPACE'];
announcement = announcementTemplate.replace('%1', blockLabel);
}
// Collapse whitespace from unused template substitutions.
aria.announceDynamicAriaState(announcement.replace(/\s+/g, ' '));
}
/**
* Checks if there are multiple compatible connections for the specified side of the pair.
*
* @param forLocal Whether we are considering the local or neighbour side of the pair
* @returns True if there are multiple compatible connections, false otherwise
*/
private hasMultipleCompatibleConnections(forLocal: boolean = true): boolean {
const connectionCandidate = this.connectionCandidate;
if (!connectionCandidate) {
return false;
}
const currentSide = forLocal ? 'local' : 'neighbour';
const oppositeSide = forLocal ? 'neighbour' : 'local';
const filteredPairs = this.allConnectionPairs.filter(
(pair) =>
pair[oppositeSide] === connectionCandidate[oppositeSide] &&
pair[currentSide] !==
connectionCandidate[currentSide].getSourceBlock().nextConnection &&
pair[currentSide].getSourceBlock().id ===
connectionCandidate[currentSide].getSourceBlock().id,
);
return filteredPairs.length > 1;
}
/**
* Handles any setup for starting the drag, including disconnecting the block
* from any parent blocks.
@@ -222,6 +277,7 @@ export class BlockDragStrategy implements IDragStrategy {
}
}
this.announceMove(true);
return this.block;
}
@@ -460,6 +516,7 @@ export class BlockDragStrategy implements IDragStrategy {
this.workspace.getAudioManager().playErrorBeep();
}
}
this.announceMove();
}
/**
@@ -767,6 +824,7 @@ export class BlockDragStrategy implements IDragStrategy {
this.block.setDragging(false);
this.dragging = false;
aria.announceDynamicAriaState(Msg['ANNOUNCE_MOVE_CANCELED']);
}
/**
@@ -824,19 +882,11 @@ export class BlockDragStrategy implements IDragStrategy {
const actualPosition = this.block.getRelativeToSurfaceXY();
const delta = Coordinate.difference(newLocation, actualPosition);
const {x, y} = delta;
if (x) {
if (x < 0) {
return Direction.LEFT;
} else if (x > 0) {
return Direction.RIGHT;
}
} else if (y) {
if (y < 0) {
return Direction.UP;
} else if (y > 0) {
return Direction.DOWN;
}
}
if (x < 0) return Direction.LEFT;
if (x > 0) return Direction.RIGHT;
if (y < 0) return Direction.UP;
if (y > 0) return Direction.DOWN;
return Direction.NONE;
}
+21 -28
View File
@@ -28,6 +28,7 @@ import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import type {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js';
import type {IRegistrable} from './interfaces/i_registrable.js';
import {ISerializable} from './interfaces/i_serializable.js';
import {Msg} from './msg.js';
import type {ConstantProvider} from './renderers/common/constants.js';
import type {KeyboardShortcut} from './shortcut_registry.js';
import * as Tooltip from './tooltip.js';
@@ -316,38 +317,34 @@ export abstract class Field<T = any>
* unspecified.
*/
getAriaTypeName(): string | null {
return this.ariaTypeName;
return this.ariaTypeName || null;
}
/**
* Gets an ARIA-friendly label representation of this field's value.
*
* Note that implementations should generally always override this value to
* ensure a non-null value is returned since the default implementation relies
* on 'getValue' which may return null, and a null return value for this
* ensure a non-null value is returned. The default implementation relies on
* 'getText' which may return an empty string. A null return value from this
* function will prompt ARIA label generation to skip the field's value
* entirely when there may be a better contextual placeholder to use, instead,
* specific to the field.
* entirely when there may be a better contextual placeholder to use isstead.
*
* For example, a text input field may have a value of null when empty. To
* avoid hiding this field from screen reader, implementations should ensure
* that if the value is null, this function would return an appropriate,
* localized value such as "empty text".
* For example, to avoid hiding an empty text input field from screen reader,
* implementations should ensure that if the text is an empty string, this
* function would return an appropriate, localized value such as "empty text".
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's value.
*
* @returns An ARIA representation of the field's value, or null if no value
* is currently defined or known for the field.
* @returns An ARIA representation of the field's text, or null if no text is
* currently defined or known for the field.
*/
getAriaValue(): string | null {
const value = this.getValue();
if (value === null || value === undefined) {
if (this.getValue() == null) {
return null;
} else {
return this.getText();
}
return String(value);
}
/**
@@ -369,28 +366,24 @@ export abstract class Field<T = any>
* checkboxes represent their checked/non-checked status (i.e. value) through
* a separate ARIA property.
*
* It's possible this returns an empty string if the field doesn't supply type
* or value information for certain cases (such as a null value). This can
* lead to the field being potentially COMPLETELY HIDDEN for screen reader
* navigation so it's crucial for implementations to ensure a non-empty value
* is returned here.
* It's not expected that this method, under normal operations, returns an empty
* string. If the field's value is empty then it will return a localized
* placeholder indicating that its value is empty.
*
* @param includeTypeInfo Whether to include the field's type information in
* the returned label, if available.
*/
computeAriaLabel(includeTypeInfo: boolean = false): string {
const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null;
const ariaValue = this.getAriaValue();
if (!ariaTypeName && !ariaValue) {
return '';
let ariaValue = this.getAriaValue();
if (ariaValue === null || ariaValue === '') {
ariaValue = Msg['FIELD_LABEL_EMPTY'];
}
if (ariaTypeName && ariaValue) {
if (ariaTypeName) {
return `${ariaTypeName}: ${ariaValue}`;
}
return ariaTypeName ?? ariaValue ?? '';
return ariaValue;
}
/**
+32
View File
@@ -15,6 +15,7 @@
import '../field_label.js';
import type {Block} from '../block.js';
import {computeFieldRowLabel, getInputLabels} from '../block_aria_composer.js';
import type {BlockSvg} from '../block_svg.js';
import type {Connection} from '../connection.js';
import {ConnectionType} from '../connection_type.js';
@@ -347,4 +348,35 @@ export class Input {
// preceding input, since they're all on one row.
return inputs[inputIndex - 1].getRowId();
}
/**
* Returns an accessibility label describing this input, including the labels
* of any fields on the input and the labels of any connected blocks, to help
* disambiguate this input from others on the same block.
*
* @internal
*/
getLabel(): string {
if (!this.isVisible()) return '';
const labels = computeFieldRowLabel(this, false);
if (this.connection?.type === ConnectionType.INPUT_VALUE) {
const childBlock = this.connection.targetBlock();
if (childBlock && !childBlock.isInsertionMarker()) {
labels.push(getInputLabels(childBlock as BlockSvg).join(' '));
}
}
return labels.join(' ');
}
/**
* Returns the index of this input on its source block.
*
* @internal
*/
getIndex(): number {
const inputs = this.getSourceBlock().inputList;
return inputs.indexOf(this);
}
}
+12 -2
View File
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2026-04-09 14:28:47.213464",
"lastupdated": "2026-04-20 12:26:14.946401",
"locale": "en",
"messagedocumentation" : "qqq"
},
@@ -438,5 +438,15 @@
"BLOCK_LABEL_HAS_INPUTS": "has inputs",
"BLOCK_LABEL_STATEMENT": "statement",
"BLOCK_LABEL_CONTAINER": "container",
"BLOCK_LABEL_VALUE": "value"
"BLOCK_LABEL_VALUE": "value",
"BLOCK_LABEL_STACK_BLOCKS": "%1 stack blocks",
"INPUT_LABEL_INDEX": "input %1",
"ANNOUNCE_MOVE_WORKSPACE": "moving %1 on workspace",
"ANNOUNCE_MOVE_BEFORE": "moving %1 before %3",
"ANNOUNCE_MOVE_AFTER": "moving %1 after %3",
"ANNOUNCE_MOVE_INSIDE": "moving %1 inside %3 %4",
"ANNOUNCE_MOVE_AROUND": "moving %1 %2 around %3",
"ANNOUNCE_MOVE_TO": "moving %1 %2 to %3 %4",
"ANNOUNCE_MOVE_CANCELED": "Canceled movement",
"FIELD_LABEL_EMPTY": "empty"
}
+11 -1
View File
@@ -445,5 +445,15 @@
"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."
"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.",
"BLOCK_LABEL_STACK_BLOCKS": "Accessibility label for a block that indicates it is a stack of two or more blocks.",
"INPUT_LABEL_INDEX": "Accessibility label for an unlabeled input that communicates its index on the block. \n\nParameters:\n* %1 - the index of the input, starting at 1",
"ANNOUNCE_MOVE_WORKSPACE": "ARIA live region message announcing a block is being moved on the workspace, without specifying a target location or specific movement direction.",
"ANNOUNCE_MOVE_BEFORE": "ARIA live region message announcing a block is being moved before another block \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks\n* %3 - the label of the target (neighbour) block \n\nExamples:\n* 'moving before repeat 10, times, do'\n* 'moving 2 stack blocks before repeat 10, times, do'",
"ANNOUNCE_MOVE_AFTER": "ARIA live region message announcing a block is being moved after another block \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks\n* %3 - the label of the target (neighbour) block \n\nExamples:\n* 'moving after repeat 10, times, do'\n* 'moving 2 stack blocks after repeat 10, times, do' \n* 'moving block A after Function block output'",
"ANNOUNCE_MOVE_INSIDE": "ARIA live region message announcing a block is being moved inside another block, optionally including connection-specific label for disambiguation.",
"ANNOUNCE_MOVE_AROUND": "ARIA live region message announcing a block is being moved around another block, optionally including connection-specific label for disambiguation. \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks \n* %2 - optional phrase describing the local connection label \n* %3 - the label of the target (neighbour) block \n\nExamples:\n* 'moving around print abc'\n* 'moving if, do else statement around print abc'",
"ANNOUNCE_MOVE_TO": "ARIA live region message announcing a block is being moved to a workspace location where the relationship is not specifically known. \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks \n* %2 - optional phrase describing the local connection label \n* %3 - the label of the target (neighbour) block or location \n* %4 - optional phrase describing the target connection label \n\nExamples:\n* 'moving to repeat 10, times, do'\n* 'moving 2 stack blocks else statement to repeat 10, times, do previous connection'",
"ANNOUNCE_MOVE_CANCELED": "ARIA live region message announcing a block movement has been canceled.",
"FIELD_LABEL_EMPTY": "Label for an empty field, used by screen readers to identify fields that have no content."
}
+52
View File
@@ -1770,3 +1770,55 @@ Blockly.Msg.BLOCK_LABEL_CONTAINER = 'container';
/// 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';
/** @type {string} */
/// Accessibility label for a block that indicates it is a stack of two or
/// more blocks.
Blockly.Msg.BLOCK_LABEL_STACK_BLOCKS = '%1 stack blocks';
/** @type {string} */
/// Accessibility label for an unlabeled input that communicates its index on the block.
/// \n\nParameters:\n* %1 - the index of the input, starting at 1
Blockly.Msg.INPUT_LABEL_INDEX = 'input %1';
/** @type {string} */
/// ARIA live region message announcing a block is being moved on the workspace, without specifying a target location or specific movement direction.
// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks
// \n\nExamples:\n* "moving block A on workspace"\n* "moving 2 stack blocks on workspace"
Blockly.Msg.ANNOUNCE_MOVE_WORKSPACE = 'moving %1 on workspace';
/** @type {string} */
/// ARIA live region message announcing a block is being moved before another block
/// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks\n* %3 - the label of the target (neighbour) block
/// \n\nExamples:\n* "moving before repeat 10, times, do"\n* "moving 2 stack blocks before repeat 10, times, do"
Blockly.Msg.ANNOUNCE_MOVE_BEFORE = 'moving %1 before %3';
/** @type {string} */
/// ARIA live region message announcing a block is being moved after another block
/// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks\n* %3 - the label of the target (neighbour) block
/// \n\nExamples:\n* "moving after repeat 10, times, do"\n* "moving 2 stack blocks after repeat 10, times, do"
/// \n* "moving block A after Function block output"
Blockly.Msg.ANNOUNCE_MOVE_AFTER = 'moving %1 after %3';
/** @type {string} */
/// ARIA live region message announcing a block is being moved inside another block, optionally including connection-specific label for disambiguation.
// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks
// \n* %3 - the label of the target (neighbour) block
// \n* %4 - optional phrase describing the target connection label
// \n\nExamples:\n* "moving inside if, do"\n* "moving 2 stack blocks inside if, do else statement"
Blockly.Msg.ANNOUNCE_MOVE_INSIDE = 'moving %1 inside %3 %4';
/** @type {string} */
/// ARIA live region message announcing a block is being moved around another block, optionally including connection-specific label for disambiguation.
/// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks
/// \n* %2 - optional phrase describing the local connection label
/// \n* %3 - the label of the target (neighbour) block
/// \n\nExamples:\n* "moving around print abc"\n* "moving if, do else statement around print abc"
Blockly.Msg.ANNOUNCE_MOVE_AROUND = 'moving %1 %2 around %3';
/** @type {string} */
/// ARIA live region message announcing a block is being moved to a workspace location where the relationship is not specifically known.
/// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks
/// \n* %2 - optional phrase describing the local connection label
/// \n* %3 - the label of the target (neighbour) block or location
/// \n* %4 - optional phrase describing the target connection label
/// \n\nExamples:\n* "moving to repeat 10, times, do"\n* "moving 2 stack blocks else statement to repeat 10, times, do previous connection"
Blockly.Msg.ANNOUNCE_MOVE_TO = 'moving %1 %2 to %3 %4';
/** @type {string} */
/// ARIA live region message announcing a block movement has been canceled.
Blockly.Msg.ANNOUNCE_MOVE_CANCELED = 'Canceled movement';
/** @type {string} */
/// Label for an empty field, used by screen readers to identify fields that have no content.
Blockly.Msg.FIELD_LABEL_EMPTY = 'empty';
+45 -4
View File
@@ -873,6 +873,11 @@ suite('Abstract Fields', function () {
const field = new TestField(undefined);
assert.isNull(field.getAriaValue());
});
test('Returns empty string for empty text value', function () {
const field = new TestField('');
assert.equal(field.getAriaValue(), '');
});
});
suite('computeAriaLabel', function () {
@@ -891,14 +896,36 @@ suite('Abstract Fields', function () {
assert.equal(field.computeAriaLabel(true), 'text: hello');
});
test('Type only when value is null', function () {
test('Type and placeholder when value is null', function () {
const field = new TestField(null, {ariaTypeName: 'text'});
assert.equal(field.computeAriaLabel(true), 'text');
assert.equal(
field.computeAriaLabel(true),
`text: ${Blockly.Msg['FIELD_LABEL_EMPTY']}`,
);
});
test('Empty string when no type or value', function () {
test('Placeholder when when value is null and no type', function () {
const field = new TestField(null);
assert.equal(field.computeAriaLabel(true), '');
assert.equal(
field.computeAriaLabel(true),
Blockly.Msg['FIELD_LABEL_EMPTY'],
);
});
test('Placeholder when value is empty string', function () {
const field = new TestField('');
assert.equal(
field.computeAriaLabel(true),
Blockly.Msg['FIELD_LABEL_EMPTY'],
);
});
test('Type and placeholder when value is empty string', function () {
const field = new TestField('', {ariaTypeName: 'text'});
assert.equal(
field.computeAriaLabel(true),
`text: ${Blockly.Msg['FIELD_LABEL_EMPTY']}`,
);
});
test('Handles missing type with includeTypeInfo=true', function () {
@@ -908,6 +935,20 @@ suite('Abstract Fields', function () {
});
suite('Subclass overrides', function () {
test('Override returning empty string still results in placeholder', function () {
class EmptyOverrideField extends TestField {
getAriaValue() {
return '';
}
}
const field = new EmptyOverrideField();
assert.equal(
field.computeAriaLabel(),
Blockly.Msg['FIELD_LABEL_EMPTY'],
);
});
class CustomValueField extends TestField {
getAriaValue() {
return 'custom value';
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {getInputLabelsSubset} from '../../build/src/core/block_aria_composer.js';
import * as Blockly from '../../build/src/core/blockly.js';
import {assert} from '../../node_modules/chai/index.js';
import {
@@ -939,6 +940,232 @@ suite('Keyboard-driven movement', function () {
}
});
});
suite('Announcement tests', function () {
setup(function () {
this.workspace.clear();
this.liveRegion = document.getElementById('blocklyAriaAnnounce');
this.moveAndAssert = (moveFn, incPhrases, exclPhrases = []) => {
moveFn(this.workspace);
this.clock.tick(11);
let text = this.liveRegion.textContent;
exclPhrases.forEach((unexpected) => {
assert.notInclude(text, unexpected);
});
incPhrases.forEach((expected) => {
assert.include(text, expected);
const index = text.indexOf(expected);
text =
text.slice(0, index) +
text.slice(index + expected.toString().length);
});
};
this.getBlockLabel = (block) =>
block.getAriaLabel(Blockly.utils.aria.Verbosity.TERSE);
this.block1 = this.workspace.newBlock('draw_emoji');
this.block1.initSvg();
this.block1.render();
});
test('announces simple block moving on workspace', function () {
Blockly.getFocusManager().focusNode(this.block1);
this.moveAndAssert(
startMove,
['moving', this.getBlockLabel(this.block1), 'on workspace'],
[],
);
cancelMove(this.workspace);
});
test('announces stack count when moving stack', function () {
const block2 = this.workspace.newBlock('draw_emoji');
block2.setFieldValue('✨', 'emoji');
block2.initSvg();
block2.render();
this.block1.nextConnection.connect(block2.previousConnection);
Blockly.getFocusManager().focusNode(this.block1);
this.moveAndAssert(startMoveStack, [
'moving',
'2 stack blocks',
'on workspace',
]);
cancelMove(this.workspace);
});
test('announces "before" when moving before a block', function () {
const block2 = this.workspace.newBlock('draw_emoji');
block2.setFieldValue('✨', 'emoji');
block2.initSvg();
block2.render();
Blockly.getFocusManager().focusNode(this.block1);
startMove(this.workspace);
this.moveAndAssert(
moveRight,
['moving', 'before', this.getBlockLabel(block2)],
[this.getBlockLabel(this.block1)],
);
cancelMove(this.workspace);
});
test('announces "after" when moving after a block', function () {
const block2 = this.workspace.newBlock('draw_emoji');
block2.setFieldValue('✨', 'emoji');
block2.initSvg();
block2.render();
this.block1.nextConnection.connect(block2.previousConnection);
Blockly.getFocusManager().focusNode(block2);
this.moveAndAssert(startMove, [
'moving',
this.getBlockLabel(block2),
'after',
this.getBlockLabel(this.block1),
]);
cancelMove(this.workspace);
});
test('announces "inside" for value connections', function () {
const valueBlock = this.workspace.newBlock('text');
valueBlock.initSvg();
valueBlock.render();
const parent = this.workspace.newBlock('text_print');
parent.initSvg();
parent.render();
Blockly.getFocusManager().focusNode(valueBlock);
startMove(this.workspace);
this.moveAndAssert(
moveRight,
['moving', 'inside', this.getBlockLabel(parent)],
[this.getBlockLabel(valueBlock)],
);
cancelMove(this.workspace);
});
test('announces "around" when wrapping a block', function () {
const loop = this.workspace.newBlock('controls_repeat_ext');
loop.initSvg();
loop.render();
Blockly.getFocusManager().focusNode(loop);
startMove(this.workspace);
moveRight(this.workspace);
this.moveAndAssert(
moveRight,
['moving', 'around', this.getBlockLabel(this.block1)],
[
this.getBlockLabel(loop),
getInputLabelsSubset(loop, loop.getInput('DO')).join(', '),
],
);
cancelMove(this.workspace);
});
test('disambiguates between multiple statement inputs', function () {
const ifBlock = this.workspace.newBlock('controls_if');
ifBlock.initSvg();
ifBlock.elseifCount_ = 1;
ifBlock.elseCount_ = 1;
ifBlock.updateShape_();
ifBlock.render();
this.workspace.cleanUp();
Blockly.getFocusManager().focusNode(ifBlock);
startMove(this.workspace); // on workspace
moveRight(this.workspace); // before block1
this.moveAndAssert(
moveRight,
[
'moving',
getInputLabelsSubset(ifBlock, ifBlock.getInput('DO1')).join(', '),
'around',
this.getBlockLabel(this.block1),
],
[this.getBlockLabel(ifBlock)],
);
this.moveAndAssert(
moveRight,
[
'moving',
getInputLabelsSubset(ifBlock, ifBlock.getInput('DO0')).join(', '),
'around',
this.getBlockLabel(this.block1),
],
[this.getBlockLabel(ifBlock)],
);
cancelMove(this.workspace);
});
test('disambiguates between multiple value inputs', function () {
const compare = this.workspace.newBlock('logic_compare');
compare.initSvg();
compare.render();
const boolean = this.workspace.newBlock('logic_boolean');
boolean.initSvg();
boolean.render();
Blockly.getFocusManager().focusNode(boolean);
startMove(this.workspace);
this.moveAndAssert(
moveRight,
[
'moving',
'inside',
this.getBlockLabel(compare),
getInputLabelsSubset(compare, compare.getInput('A')).join(', '),
],
[this.getBlockLabel(boolean)],
);
this.moveAndAssert(
moveRight,
[
'moving',
'inside',
this.getBlockLabel(compare),
getInputLabelsSubset(compare, compare.getInput('B')).join(', '),
],
[this.getBlockLabel(boolean)],
);
cancelMove(this.workspace);
});
test('disambiguates between unlabeled value inputs', function () {
const textJoin = this.workspace.newBlock('text_join');
textJoin.itemCount_ = 3;
textJoin.updateShape_();
textJoin.initSvg();
textJoin.render();
const text = this.workspace.newBlock('text');
text.initSvg();
text.render();
Blockly.getFocusManager().focusNode(text);
startMove(this.workspace);
moveRight(this.workspace); // First labeled input
this.moveAndAssert(
moveRight,
['moving', 'inside', this.getBlockLabel(textJoin), 'input 2'],
[this.getBlockLabel(text)],
);
this.moveAndAssert(
moveRight,
['moving', 'inside', this.getBlockLabel(textJoin), 'input 3'],
[this.getBlockLabel(text)],
);
cancelMove(this.workspace);
});
});
});
suite('of bubbles', function () {