Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 199 additions & 14 deletions packages/blockly/core/block_aria_composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Comment thread
mikeharv marked this conversation as resolved.
UNKNOWN,
BEFORE,
AFTER,
AROUND,
INSIDE,
}

/**
* Returns an ARIA representation of the specified block.
*
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);

if (input.connection?.type === ConnectionType.INPUT_VALUE) {
const childBlock = input.connection.targetBlock();
if (childBlock) {
labels.push(getInputLabels(childBlock as BlockSvg).join(' '));
}
}
.map((input) => input.getLabel());
}

return labels;
});
/**
* 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}".`,
);
}

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;
}

/**
Expand Down Expand Up @@ -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.
*
Expand All @@ -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'];
}

Expand Down
21 changes: 21 additions & 0 deletions packages/blockly/core/block_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Loading