mirror of
https://github.com/google/blockly.git
synced 2026-04-27 07:30:21 +02:00
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:
@@ -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'];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user