From 16767eaaa2cb2def7ce4b746f930a832d4b9ee48 Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:12:02 -0400 Subject: [PATCH] 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 --- packages/blockly/core/block_aria_composer.ts | 211 +++++++++++++++- packages/blockly/core/block_svg.ts | 21 ++ .../core/dragging/block_drag_strategy.ts | 76 +++++- packages/blockly/core/field.ts | 49 ++-- packages/blockly/core/inputs/input.ts | 32 +++ packages/blockly/msg/json/en.json | 14 +- packages/blockly/msg/json/qqq.json | 12 +- packages/blockly/msg/messages.js | 52 ++++ packages/blockly/tests/mocha/field_test.js | 49 +++- .../tests/mocha/keyboard_movement_test.js | 227 ++++++++++++++++++ 10 files changed, 682 insertions(+), 61 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index fa06c4894..925512307 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -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']; } diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 8a682ab39..3b4aac5c1 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -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()); + } } diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index c521e8b11..fc09eb8eb 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -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; } diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index 9f218288f..e8b83c574 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -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 * 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 * 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; } /** diff --git a/packages/blockly/core/inputs/input.ts b/packages/blockly/core/inputs/input.ts index ee5f7fdc0..8eb27387c 100644 --- a/packages/blockly/core/inputs/input.ts +++ b/packages/blockly/core/inputs/input.ts @@ -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); + } } diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 980cd43d4..a1c94aae0 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-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" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index e42f1ff5e..0fa1a2cd6 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -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." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index e9f7dfa9a..9d6162eea 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -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'; \ No newline at end of file diff --git a/packages/blockly/tests/mocha/field_test.js b/packages/blockly/tests/mocha/field_test.js index 7736c6b50..373f44133 100644 --- a/packages/blockly/tests/mocha/field_test.js +++ b/packages/blockly/tests/mocha/field_test.js @@ -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'; diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index 115275e63..11b7e929b 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -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 () {