mirror of
https://github.com/google/blockly.git
synced 2026-04-26 23:20:22 +02:00
feat: Add basic support for generating ARIA labels and roles for blocks (#9696)
* feat: Add basic support for generating ARIA labels and roles for blocks * test: Add tests * chore: Fix lint * chore: Revert tooling removal of authors * chore: Adjust casing of method name * chore: Tweak name of verbosity enum value * chore: Adjust name of shadow block label method * chore: Add trailing newline * chore: Fix method casing * feat: Add method to retrieve a block's ARIA label * fix: Fix TSDoc * chore: Adjust method casing
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Raspberry Pi Foundation
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {BlockSvg} from './block_svg.js';
|
||||
import {ConnectionType} from './connection_type.js';
|
||||
import type {Input} from './inputs/input.js';
|
||||
import {inputTypes} from './inputs/input_types.js';
|
||||
import {
|
||||
ISelectableToolboxItem,
|
||||
isSelectableToolboxItem,
|
||||
} from './interfaces/i_selectable_toolbox_item.js';
|
||||
import {Msg} from './msg.js';
|
||||
import {Role, setRole, setState, State, Verbosity} from './utils/aria.js';
|
||||
|
||||
/**
|
||||
* Returns an ARIA representation of the specified block.
|
||||
*
|
||||
* The returned label will contain a complete context of the block, including:
|
||||
* - Whether it begins a block stack or statement input stack.
|
||||
* - Its constituent editable and non-editable fields.
|
||||
* - Properties, including: disabled, collapsed, replaceable (a shadow), etc.
|
||||
* - Its parent toolbox category.
|
||||
* - Whether it has inputs.
|
||||
*
|
||||
* Beyond this, the returned label is specifically assembled with commas in
|
||||
* select locations with the intention of better 'prosody' in the screen reader
|
||||
* readouts since there's a lot of information being shared with the user. The
|
||||
* returned label also places more important information earlier in the label so
|
||||
* that the user gets the most important context as soon as possible in case
|
||||
* they wish to stop readout early.
|
||||
*
|
||||
* The returned label will be specialized based on whether the block is part of a
|
||||
* flyout.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block for which an ARIA representation should be created.
|
||||
* @param verbosity How much detail to include in the description.
|
||||
* @returns The ARIA representation for the specified block.
|
||||
*/
|
||||
export function computeAriaLabel(
|
||||
block: BlockSvg,
|
||||
verbosity = Verbosity.STANDARD,
|
||||
) {
|
||||
return [
|
||||
getBeginStackLabel(block),
|
||||
getParentInputLabel(block),
|
||||
...getInputLabels(block),
|
||||
verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block),
|
||||
verbosity >= Verbosity.STANDARD && getDisabledLabel(block),
|
||||
verbosity >= Verbosity.STANDARD && getCollapsedLabel(block),
|
||||
verbosity >= Verbosity.STANDARD && getShadowBlockLabel(block),
|
||||
verbosity >= Verbosity.STANDARD && getInputCountLabel(block),
|
||||
]
|
||||
.filter((label) => !!label)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the ARIA role and role description for the specified block, accounting
|
||||
* for whether the block is part of a flyout.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to set ARIA role and roledescription attributes on.
|
||||
*/
|
||||
export function configureAriaRole(block: BlockSvg) {
|
||||
setRole(block.getSvgRoot(), block.isInFlyout ? Role.LISTITEM : Role.FIGURE);
|
||||
|
||||
let roleDescription = Msg['BLOCK_LABEL_STATEMENT'];
|
||||
if (block.statementInputCount) {
|
||||
roleDescription = Msg['BLOCK_LABEL_CONTAINER'];
|
||||
} else if (block.outputConnection) {
|
||||
roleDescription = Msg['BLOCK_LABEL_VALUE'];
|
||||
}
|
||||
|
||||
setState(block.getSvgRoot(), State.ROLEDESCRIPTION, roleDescription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of ARIA labels for the 'field row' for the specified Input.
|
||||
*
|
||||
* 'Field row' essentially means the horizontal run of readable fields that
|
||||
* precede the Input. Together, these provide the domain context for the input,
|
||||
* particularly in the context of connections. In some cases, there may not be
|
||||
* any readable fields immediately prior to the Input. In that case, if the
|
||||
* `lookback` attribute is specified, all of the fields on the row immediately
|
||||
* above the Input will be used instead.
|
||||
*
|
||||
* @internal
|
||||
* @param input The Input to compute a description/context label for.
|
||||
* @param lookback If true, will use labels for fields on the previous row if
|
||||
* the given input's row has no fields itself.
|
||||
* @returns A list of labels for fields on the same row (or previous row, if
|
||||
* lookback is specified) as the given input.
|
||||
*/
|
||||
export function computeFieldRowLabel(
|
||||
input: Input,
|
||||
lookback: boolean,
|
||||
): string[] {
|
||||
const fieldRowLabel = input.fieldRow
|
||||
.filter((field) => field.isVisible())
|
||||
.map((field) => field.computeAriaLabel(true));
|
||||
if (!fieldRowLabel.length && lookback) {
|
||||
const inputs = input.getSourceBlock().inputList;
|
||||
const index = inputs.indexOf(input);
|
||||
if (index > 0) {
|
||||
return computeFieldRowLabel(inputs[index - 1], lookback);
|
||||
}
|
||||
}
|
||||
return fieldRowLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a description of the parent statement input a block is attached to.
|
||||
* When a block is connected to a statement input, the input's field row label
|
||||
* will be prepended to the block's description to indicate that the block
|
||||
* begins a clause in its parent block.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to generate a parent input label for.
|
||||
* @returns A description of the block's parent statement input, or undefined
|
||||
* for blocks that do not have one.
|
||||
*/
|
||||
function getParentInputLabel(block: BlockSvg) {
|
||||
const parentInput = (
|
||||
block.outputConnection ?? block.previousConnection
|
||||
)?.targetConnection?.getParentInput();
|
||||
const parentBlock = parentInput?.getSourceBlock();
|
||||
|
||||
if (!parentBlock?.statementInputCount) return undefined;
|
||||
|
||||
const firstStatementInput = parentBlock.inputList.find(
|
||||
(i) => i.type === inputTypes.STATEMENT,
|
||||
);
|
||||
// The first statement input in a block has no field row label as it would
|
||||
// be duplicative of the block's label.
|
||||
if (!parentInput || parentInput === firstStatementInput) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parentInputLabel = computeFieldRowLabel(parentInput, true);
|
||||
return parentInput.type === inputTypes.STATEMENT
|
||||
? Msg['BLOCK_LABEL_BEGIN_PREFIX'].replace('%1', parentInputLabel.join(' '))
|
||||
: parentInputLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text indicating that a block is the root block of a stack.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to retrieve a label for.
|
||||
* @returns Text indicating that the block begins a stack, or undefined if it
|
||||
* does not.
|
||||
*/
|
||||
function getBeginStackLabel(block: BlockSvg) {
|
||||
return !block.workspace.isFlyout && block.getRootBlock() === block
|
||||
? Msg['BLOCK_LABEL_BEGIN_STACK']
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of accessibility labels for fields and inputs on a block.
|
||||
* Each entry in the returned array corresponds to one of: (a) a label for a
|
||||
* continuous run of non-interactable fields, (b) a label for an editable field,
|
||||
* (c) a label for an input. When an input contains nested blocks/fields/inputs,
|
||||
* their contents are returned as a single item in the array per top-level
|
||||
* input.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to retrieve a list of field/input labels for.
|
||||
* @returns A list of field/input labels for the given block.
|
||||
*/
|
||||
function getInputLabels(block: BlockSvg): string[] {
|
||||
return block.inputList
|
||||
.filter((input) => input.isVisible())
|
||||
.flatMap((input) => {
|
||||
const labels = computeFieldRowLabel(input, false);
|
||||
|
||||
if (input.connection?.type === ConnectionType.INPUT_VALUE) {
|
||||
const childBlock = input.connection.targetBlock();
|
||||
if (childBlock) {
|
||||
labels.push(getInputLabels(childBlock as BlockSvg).join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the toolbox category that the given block is part of.
|
||||
* This is heuristic-based; each toolbox category's contents are enumerated, and
|
||||
* if a block with the given block's type is encountered, that category is
|
||||
* deemed to be its parent. As a fallback, a toolbox category with the same
|
||||
* colour as the block may be returned. This is not comprehensive; blocks may
|
||||
* exist on the workspace which are not part of any category, or a given block
|
||||
* type may be part of multiple categories or belong to a dynamically-generated
|
||||
* category, or there may not even be a toolbox at all. In these cases, either
|
||||
* the first matching category or undefined will be returned.
|
||||
*
|
||||
* This method exists to attempt to provide similar context as block colour
|
||||
* provides to sighted users, e.g. where a red block comes from a red category.
|
||||
* It is inherently best-effort due to the above-mentioned constraints.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to retrieve a category name for.
|
||||
* @returns A description of the given block's parent toolbox category if any,
|
||||
* otherwise undefined.
|
||||
*/
|
||||
function getParentToolboxCategoryLabel(block: BlockSvg) {
|
||||
const toolbox = block.workspace.getToolbox();
|
||||
if (!toolbox) return undefined;
|
||||
|
||||
let parentCategory: ISelectableToolboxItem | undefined = undefined;
|
||||
for (const category of toolbox.getToolboxItems()) {
|
||||
if (!isSelectableToolboxItem(category)) continue;
|
||||
|
||||
const contents = category.getContents();
|
||||
if (
|
||||
Array.isArray(contents) &&
|
||||
contents.some(
|
||||
(item) =>
|
||||
item.kind.toLowerCase() === 'block' &&
|
||||
'type' in item &&
|
||||
item.type === block.type,
|
||||
)
|
||||
) {
|
||||
parentCategory = category;
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
'getColour' in category &&
|
||||
typeof category.getColour === 'function' &&
|
||||
category.getColour() === block.getColour()
|
||||
) {
|
||||
parentCategory = category;
|
||||
}
|
||||
}
|
||||
|
||||
if (parentCategory) {
|
||||
return Msg['BLOCK_LABEL_TOOLBOX_CATEGORY'].replace(
|
||||
'%1',
|
||||
parentCategory.getName(),
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label indicating that the block is disabled.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to generate a label for.
|
||||
* @returns A label indicating that the block is disabled (if it is), otherwise
|
||||
* undefined.
|
||||
*/
|
||||
export function getDisabledLabel(block: BlockSvg) {
|
||||
return block.isEnabled() ? undefined : Msg['BLOCK_LABEL_DISABLED'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label indicating that the block is collapsed.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to generate a label for.
|
||||
* @returns A label indicating that the block is collapsed (if it is), otherwise
|
||||
* undefined.
|
||||
*/
|
||||
function getCollapsedLabel(block: BlockSvg) {
|
||||
return block.isCollapsed() ? Msg['BLOCK_LABEL_COLLAPSED'] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label indicating that the block is a shadow block.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to generate a label for.
|
||||
* @returns A label indicating that the block is a shadow (if it is), otherwise
|
||||
* undefined.
|
||||
*/
|
||||
function getShadowBlockLabel(block: BlockSvg) {
|
||||
return block.isShadow() ? Msg['BLOCK_LABEL_REPLACEABLE'] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label indicating whether the block has one or multiple inputs.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to generate a label for.
|
||||
* @returns A label indicating that the block has one or multiple inputs,
|
||||
* otherwise undefined.
|
||||
*/
|
||||
function getInputCountLabel(block: BlockSvg) {
|
||||
const inputCount = block.inputList.reduce((totalSum, input) => {
|
||||
return (
|
||||
input.fieldRow.reduce((fieldCount, field) => {
|
||||
return field.EDITABLE && !field.isFullBlockField()
|
||||
? fieldCount++
|
||||
: fieldCount;
|
||||
}, totalSum) +
|
||||
(input.connection?.type === ConnectionType.INPUT_VALUE ? 1 : 0)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
switch (inputCount) {
|
||||
case 0:
|
||||
return undefined;
|
||||
case 1:
|
||||
return Msg['BLOCK_LABEL_HAS_INPUT'];
|
||||
default:
|
||||
return Msg['BLOCK_LABEL_HAS_INPUTS'];
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import './events/events_selected.js';
|
||||
|
||||
import {Block} from './block.js';
|
||||
import * as blockAnimations from './block_animations.js';
|
||||
import {computeAriaLabel, configureAriaRole} from './block_aria_composer.js';
|
||||
import * as browserEvents from './browser_events.js';
|
||||
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
|
||||
import * as common from './common.js';
|
||||
@@ -62,6 +63,7 @@ import * as blocks from './serialization/blocks.js';
|
||||
import type {BlockStyle} from './theme.js';
|
||||
import * as Tooltip from './tooltip.js';
|
||||
import {idGenerator} from './utils.js';
|
||||
import * as aria from './utils/aria.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import {Rect} from './utils/rect.js';
|
||||
@@ -244,6 +246,7 @@ export class BlockSvg
|
||||
if (!svg.parentNode) {
|
||||
this.workspace.getCanvas().appendChild(svg);
|
||||
}
|
||||
this.recomputeAriaAttributes();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -606,6 +609,7 @@ export class BlockSvg
|
||||
this.getInput(collapsedInputName) ||
|
||||
this.appendDummyInput(collapsedInputName);
|
||||
input.appendField(new FieldLabel(text), collapsedFieldName);
|
||||
this.recomputeAriaAttributes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -842,6 +846,7 @@ export class BlockSvg
|
||||
override setShadow(shadow: boolean) {
|
||||
super.setShadow(shadow);
|
||||
this.applyColour();
|
||||
this.recomputeAriaAttributes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1062,6 +1067,7 @@ export class BlockSvg
|
||||
for (const child of this.getChildren(false)) {
|
||||
child.updateDisabled();
|
||||
}
|
||||
this.recomputeAriaAttributes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1885,6 +1891,7 @@ export class BlockSvg
|
||||
|
||||
/** See IFocusableNode.onNodeFocus. */
|
||||
onNodeFocus(): void {
|
||||
this.recomputeAriaAttributes();
|
||||
this.select();
|
||||
if (getFocusManager().getFocusedNode() !== this) {
|
||||
renderManagement.finishQueuedRenders().then(() => {
|
||||
@@ -1986,4 +1993,23 @@ export class BlockSvg
|
||||
// All other blocks are their own row.
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the ARIA label, role and roledescription for this block.
|
||||
*/
|
||||
private recomputeAriaAttributes() {
|
||||
aria.setState(this.getSvgRoot(), aria.State.LABEL, computeAriaLabel(this));
|
||||
configureAriaRole(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a description of this block suitable for screenreaders or use in
|
||||
* ARIA attributes.
|
||||
*
|
||||
* @param verbosity How much detail to include in the description.
|
||||
* @returns An accessibility description of this block.
|
||||
*/
|
||||
getAriaLabel(verbosity: aria.Verbosity) {
|
||||
return computeAriaLabel(this, verbosity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,15 @@ export enum State {
|
||||
VALUEMIN = 'valuemin',
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to control how verbose generated a11y labels are.
|
||||
*/
|
||||
export enum Verbosity {
|
||||
TERSE,
|
||||
STANDARD,
|
||||
LOQUACIOUS,
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the ARIA role from an element.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"@metadata": {
|
||||
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
|
||||
"lastupdated": "2026-04-03 10:36:19.846436",
|
||||
"lastupdated": "2026-04-09 14:28:47.213464",
|
||||
"locale": "en",
|
||||
"messagedocumentation" : "qqq"
|
||||
},
|
||||
@@ -427,5 +427,16 @@
|
||||
"WORKSPACE_CONTENTS_COMMENTS_MANY": " and %1 comments",
|
||||
"WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment",
|
||||
"KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Use the right arrow key to navigate inside of blocks",
|
||||
"KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Use the arrow keys to navigate"
|
||||
"KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Use the arrow keys to navigate",
|
||||
"BLOCK_LABEL_BEGIN_STACK": "Begin stack",
|
||||
"BLOCK_LABEL_BEGIN_PREFIX": "Begin %1",
|
||||
"BLOCK_LABEL_TOOLBOX_CATEGORY": "%1 category",
|
||||
"BLOCK_LABEL_DISABLED": "disabled",
|
||||
"BLOCK_LABEL_COLLAPSED": "collapsed",
|
||||
"BLOCK_LABEL_REPLACEABLE": "replaceable",
|
||||
"BLOCK_LABEL_HAS_INPUT": "has input",
|
||||
"BLOCK_LABEL_HAS_INPUTS": "has inputs",
|
||||
"BLOCK_LABEL_STATEMENT": "statement",
|
||||
"BLOCK_LABEL_CONTAINER": "container",
|
||||
"BLOCK_LABEL_VALUE": "value"
|
||||
}
|
||||
|
||||
@@ -434,5 +434,16 @@
|
||||
"WORKSPACE_CONTENTS_COMMENTS_MANY": "ARIA live region phrase appended when there are multiple workspace comments. \n\nParameters:\n* %1 - the number of comments (integer greater than 1)",
|
||||
"WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment.",
|
||||
"KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Message shown when a user presses Enter with a navigable block focused.",
|
||||
"KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Message shown when a user presses Enter with the workspace focused."
|
||||
"KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Message shown when a user presses Enter with the workspace focused.",
|
||||
"BLOCK_LABEL_BEGIN_STACK": "Part of an accessibility label for a block that indicates it is the first block in the stack.",
|
||||
"BLOCK_LABEL_BEGIN_PREFIX": "Part of an accessibility label for a block that indicates it is the first block inside of a statement input. Placeholder corresponds to the parent statement input's accessibility label.",
|
||||
"BLOCK_LABEL_TOOLBOX_CATEGORY": "Part of an accessibility label for a block that indicates its parent toolbox category. Placeholder corresponds to a category name, e.g. 'Logic' or 'Math'.",
|
||||
"BLOCK_LABEL_DISABLED": "Part of an accessibility label for a block that indicates that it is disabled.",
|
||||
"BLOCK_LABEL_COLLAPSED": "Part of an accessibility label for a block that indicates that it is collapsed.",
|
||||
"BLOCK_LABEL_REPLACEABLE": "Part of an accessibility label for a block that indicates that it is replaceable, i.e. that it is a shadow block.",
|
||||
"BLOCK_LABEL_HAS_INPUT": "Part of an accessibility label for a block that indicates that it has a single input.",
|
||||
"BLOCK_LABEL_HAS_INPUTS": "Part of an accessibility label for a block that indicates that it has more than one input.",
|
||||
"BLOCK_LABEL_STATEMENT": "Part of an accessibility label for a block that indicates that it is a statement block, i.e. that it has a next or previous connection.",
|
||||
"BLOCK_LABEL_CONTAINER": "Part of an accessibility label for a block that indicates that it is a container block, i.e. that it has one or more statement inputs.",
|
||||
"BLOCK_LABEL_VALUE": "Part of an accessibility label for a block that indicates that it is a value block, i.e. that it has an output connection."
|
||||
}
|
||||
|
||||
@@ -1724,3 +1724,49 @@ Blockly.Msg.KEYBOARD_NAV_BLOCK_NAVIGATION_HINT = 'Use the right arrow key to nav
|
||||
/** @type {string} */
|
||||
/// Message shown when a user presses Enter with the workspace focused.
|
||||
Blockly.Msg.KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT = 'Use the arrow keys to navigate';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates it is the first
|
||||
/// block in the stack.
|
||||
Blockly.Msg.BLOCK_LABEL_BEGIN_STACK = 'Begin stack';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates it is the first
|
||||
/// block inside of a statement input. Placeholder corresponds to the parent
|
||||
/// statement input's accessibility label.
|
||||
Blockly.Msg.BLOCK_LABEL_BEGIN_PREFIX = 'Begin %1';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates its parent toolbox
|
||||
/// category. Placeholder corresponds to a category name, e.g. "Logic" or
|
||||
/// "Math".
|
||||
Blockly.Msg.BLOCK_LABEL_TOOLBOX_CATEGORY = '%1 category';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates that it is
|
||||
/// disabled.
|
||||
Blockly.Msg.BLOCK_LABEL_DISABLED = 'disabled';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates that it is
|
||||
/// collapsed.
|
||||
Blockly.Msg.BLOCK_LABEL_COLLAPSED = 'collapsed';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates that it is
|
||||
/// replaceable, i.e. that it is a shadow block.
|
||||
Blockly.Msg.BLOCK_LABEL_REPLACEABLE = 'replaceable';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates that it has a
|
||||
/// single input.
|
||||
Blockly.Msg.BLOCK_LABEL_HAS_INPUT = 'has input';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates that it has more
|
||||
/// than one input.
|
||||
Blockly.Msg.BLOCK_LABEL_HAS_INPUTS = 'has inputs';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates that it is
|
||||
/// a statement block, i.e. that it has a next or previous connection.
|
||||
Blockly.Msg.BLOCK_LABEL_STATEMENT = 'statement';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates that it is
|
||||
/// a container block, i.e. that it has one or more statement inputs.
|
||||
Blockly.Msg.BLOCK_LABEL_CONTAINER = 'container';
|
||||
/** @type {string} */
|
||||
/// Part of an accessibility label for a block that indicates that it is
|
||||
/// a value block, i.e. that it has an output connection.
|
||||
Blockly.Msg.BLOCK_LABEL_VALUE = 'value';
|
||||
|
||||
@@ -10,10 +10,24 @@ import {
|
||||
sharedTestTeardown,
|
||||
} from './test_helpers/setup_teardown.js';
|
||||
|
||||
suite('Aria', function () {
|
||||
suite('ARIA', function () {
|
||||
setup(function () {
|
||||
sharedTestSetup.call(this);
|
||||
this.workspace = Blockly.inject('blocklyDiv', {});
|
||||
Blockly.defineBlocksWithJsonArray([
|
||||
{
|
||||
type: 'basic_block',
|
||||
message0: '%1',
|
||||
args0: [
|
||||
{
|
||||
type: 'field_input',
|
||||
name: 'TEXT',
|
||||
text: 'default',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const toolbox = document.getElementById('toolbox-categories');
|
||||
this.workspace = Blockly.inject('blocklyDiv', {toolbox});
|
||||
this.liveRegion = document.getElementById('blocklyAriaAnnounce');
|
||||
});
|
||||
|
||||
@@ -263,4 +277,177 @@ suite('Aria', function () {
|
||||
assert.equal(element.getAttribute('aria-label'), 'one two three');
|
||||
});
|
||||
});
|
||||
|
||||
suite('Blocks', function () {
|
||||
setup(function () {
|
||||
this.makeBlock = (blockType) => {
|
||||
const block = this.workspace.newBlock(blockType);
|
||||
block.initSvg();
|
||||
block.render();
|
||||
Blockly.getFocusManager().focusNode(block);
|
||||
return block;
|
||||
};
|
||||
});
|
||||
|
||||
test('Statement blocks have correct role description', function () {
|
||||
const block = this.makeBlock('text_print');
|
||||
const roleDescription = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.ROLEDESCRIPTION,
|
||||
);
|
||||
assert.equal(roleDescription, 'statement');
|
||||
});
|
||||
|
||||
test('Value blocks have correct role description', function () {
|
||||
const block = this.makeBlock('logic_boolean');
|
||||
const roleDescription = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.ROLEDESCRIPTION,
|
||||
);
|
||||
assert.equal(roleDescription, 'value');
|
||||
});
|
||||
|
||||
test('Container blocks have correct role description', function () {
|
||||
const block = this.makeBlock('controls_if');
|
||||
const roleDescription = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.ROLEDESCRIPTION,
|
||||
);
|
||||
assert.equal(roleDescription, 'container');
|
||||
});
|
||||
|
||||
test('Workspace blocks have the correct role', function () {
|
||||
const block = this.makeBlock('text_print');
|
||||
const role = Blockly.utils.aria.getRole(block.getSvgRoot());
|
||||
assert.equal(role, Blockly.utils.aria.Role.FIGURE);
|
||||
});
|
||||
|
||||
test('Flyout blocks have the correct role', function () {
|
||||
Blockly.getFocusManager().focusNode(
|
||||
this.workspace.getToolbox().getToolboxItems()[0],
|
||||
);
|
||||
const block = this.workspace.getFlyout().getWorkspace().getTopBlocks()[0];
|
||||
const role = Blockly.utils.aria.getRole(block.getSvgRoot());
|
||||
assert.equal(role, Blockly.utils.aria.Role.LISTITEM);
|
||||
});
|
||||
|
||||
test('Root workspace blocks indicate that in their labels', function () {
|
||||
const block = this.makeBlock('text_print');
|
||||
const label = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.isTrue(label.startsWith('Begin stack'));
|
||||
});
|
||||
|
||||
test('Flyout blocks are not labeled as beginning a stack', function () {
|
||||
Blockly.getFocusManager().focusNode(
|
||||
this.workspace.getToolbox().getToolboxItems()[0],
|
||||
);
|
||||
const block = this.workspace.getFlyout().getWorkspace().getTopBlocks()[0];
|
||||
const label = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.notInclude(label, 'Begin stack');
|
||||
});
|
||||
|
||||
test('Nested statement blocks in first statement input do not include their parent input in their label', function () {
|
||||
const ifBlock = this.makeBlock('controls_ifelse');
|
||||
const printBlock = this.makeBlock('text_print');
|
||||
ifBlock.getInput('IF0').connection.connect(printBlock.previousConnection);
|
||||
const label = Blockly.utils.aria.getState(
|
||||
printBlock.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.isFalse(label.startsWith('Begin do'));
|
||||
});
|
||||
|
||||
test('Nested statement blocks in subsequent statement inputs include their parent input in their label', function () {
|
||||
const ifBlock = this.makeBlock('controls_ifelse');
|
||||
const printBlock = this.makeBlock('text_print');
|
||||
ifBlock
|
||||
.getInput('ELSE')
|
||||
.connection.connect(printBlock.previousConnection);
|
||||
const label = Blockly.utils.aria.getState(
|
||||
printBlock.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.isTrue(label.startsWith('Begin else'));
|
||||
});
|
||||
|
||||
test('Disabled blocks indicate that in their label', function () {
|
||||
const block = this.makeBlock('text_print');
|
||||
let label = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.notInclude(label, 'disabled');
|
||||
block.setDisabledReason(true, 'testing');
|
||||
label = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.include(label, 'disabled');
|
||||
});
|
||||
|
||||
test('Collapsed blocks indicate that in their label', function () {
|
||||
const block = this.makeBlock('text_print');
|
||||
let label = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.notInclude(label, 'collapsed');
|
||||
block.setCollapsed(true);
|
||||
label = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.include(label, 'collapsed');
|
||||
});
|
||||
|
||||
test('Shadow blocks indicate that in their label', function () {
|
||||
const block = this.makeBlock('text_print');
|
||||
const text = this.makeBlock('text');
|
||||
text.outputConnection.connect(block.inputList[0].connection);
|
||||
let label = Blockly.utils.aria.getState(
|
||||
text.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.notInclude(label, 'replaceable');
|
||||
text.setShadow(true);
|
||||
label = Blockly.utils.aria.getState(
|
||||
text.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.include(label, 'replaceable');
|
||||
});
|
||||
|
||||
test('Blocks without inputs are properly labeled', function () {
|
||||
const block = this.makeBlock('math_random_float');
|
||||
const label = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.notInclude(label, 'input');
|
||||
});
|
||||
|
||||
test('Blocks with one input are properly labeled', function () {
|
||||
const block = this.makeBlock('logic_negate');
|
||||
const label = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.isTrue(label.endsWith('has input'));
|
||||
});
|
||||
|
||||
test('Blocks with multiple inputs are properly labeled', function () {
|
||||
const block = this.makeBlock('logic_ternary');
|
||||
const label = Blockly.utils.aria.getState(
|
||||
block.getSvgRoot(),
|
||||
Blockly.utils.aria.State.LABEL,
|
||||
);
|
||||
assert.isTrue(label.endsWith('has inputs'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user