fix: improve block labels and aria roles (#9834)

* fix: improve block labels and aria roles

* chore: rename and grammar

* fix: use custom labels for move mode still

* chore: minor logic refactor
This commit is contained in:
Maribeth Moffatt
2026-05-11 16:16:07 -04:00
committed by GitHub
parent c185d0fa9b
commit d739be3946
16 changed files with 254 additions and 135 deletions
+24 -1
View File
@@ -967,7 +967,30 @@ export class Block {
}
/**
* @returns True if this block is a value block with a single editable field.
* Determines and returns the full-block field for this block, or null if there isn't one
* and this block can't be considered a singleton field block.
*
* Note that this method is unreliable if a block contains a single field that
* hasn't been initialized/rendered yet.
*
* @returns The full-block field this block contains, or null if it doesn't contain one.
* @internal
*/
getFullBlockField(): Field<any> | null {
if (!this.isSimpleReporter()) return null;
const field = this.inputList[0]?.fieldRow[0];
return field?.isFullBlockField() ? field : null;
}
/**
* A block is a simple reporter if it has an output connection and exactly one field.
* In some renderers, simple reporters are rendered differently from other blocks.
* Being a simple reporter block is a prerequisite to the single field rendering itself
* as a "full-block field", but it is not sufficient, as not all fields or renderers use
* this special rendering. Use `getFullBlockField` to determine if the block is rendered
* as a "full-block field block".
*
* @returns True if this block is a value block with a single field.
* @internal
*/
isSimpleReporter(): boolean {
+30 -5
View File
@@ -52,20 +52,31 @@ export enum ConnectionPreposition {
* @internal
* @param block The block for which an ARIA representation should be created.
* @param verbosity How much detail to include in the description.
* @param useCustomInputLabels Whether to use custom labels for inputs, if they
* exist. We don't want to do this when just reading a block's label, but do
* want to in other scenarios such as move mode.
* @returns The ARIA representation for the specified block.
*/
export function computeAriaLabel(
block: BlockSvg,
verbosity = Verbosity.STANDARD,
useCustomInputLabels = true,
) {
if (block.isSimpleReporter()) {
// special case for full-block field blocks.
const field = block.getFullBlockField();
if (field) {
return field.computeAriaLabel(verbosity >= Verbosity.STANDARD);
}
}
return [
verbosity >= Verbosity.STANDARD && getBeginStackLabel(block),
getParentInputLabel(block),
...getInputLabels(block, verbosity),
...getInputLabels(block, verbosity, useCustomInputLabels),
verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block),
verbosity >= Verbosity.STANDARD && getDisabledLabel(block),
verbosity >= Verbosity.STANDARD && getCollapsedLabel(block),
verbosity >= Verbosity.STANDARD && getShadowBlockLabel(block),
verbosity >= Verbosity.LOQUACIOUS && getShadowBlockLabel(block),
verbosity >= Verbosity.STANDARD && getInputCountLabel(block),
]
.filter((label) => !!label)
@@ -124,7 +135,7 @@ export function computeFieldRowLabel(
lookback: boolean,
verbosity = Verbosity.STANDARD,
): string[] {
const includeTypeInfo = verbosity >= Verbosity.STANDARD;
const includeTypeInfo = verbosity >= Verbosity.LOQUACIOUS;
const fieldRowLabel = input.fieldRow
.filter((field) => field.isVisible())
.map((field) => field.computeAriaLabel(includeTypeInfo));
@@ -182,7 +193,10 @@ function getParentInputLabel(block: BlockSvg) {
* does not.
*/
function getBeginStackLabel(block: BlockSvg) {
return !block.workspace.isFlyout && block.getRootBlock() === block
// Don't include the "begin stack" label for blocks that are moving
// or blocks in the flyout
if (block.isInFlyout || block.workspace.isDragging()) return undefined;
return block.getRootBlock() === block
? Msg['BLOCK_LABEL_BEGIN_STACK']
: undefined;
}
@@ -195,17 +209,28 @@ function getBeginStackLabel(block: BlockSvg) {
* their contents are returned as a single item in the array per top-level
* input.
*
* Generally, if a custom label for an input is provided, that is preferred.
* However, we do not surface the custom labels when simply reading the text of
* the block. They are used as supplementary information for situations like
* move mode or when an input itself is focused.
*
* @internal
* @param block The block to retrieve a list of field/input labels for.
* @param verbosity
* @param useCustomLabels whether to use the custom label for an input, if it's present.
* @returns A list of field/input labels for the given block.
*/
export function getInputLabels(
block: BlockSvg,
verbosity = Verbosity.STANDARD,
useCustomLabels = true,
): string[] {
return block.inputList
.filter((input) => input.isVisible())
.map((input) => input.getAriaLabelText() ?? input.getLabel(verbosity));
.map((input) => {
const customLabel = useCustomLabels ? input.getAriaLabelText() : null;
return customLabel ?? input.getLabel(verbosity);
});
}
/**
+14 -8
View File
@@ -246,7 +246,7 @@ export class BlockSvg
if (!svg.parentNode) {
this.workspace.getCanvas().appendChild(svg);
}
this.recomputeAriaAttributes();
this.recomputeAriaContext();
this.initialized = true;
}
@@ -609,7 +609,7 @@ export class BlockSvg
this.getInput(collapsedInputName) ||
this.appendDummyInput(collapsedInputName);
input.appendField(new FieldLabel(text), collapsedFieldName);
this.recomputeAriaAttributes();
this.recomputeAriaContext();
}
/**
@@ -846,7 +846,7 @@ export class BlockSvg
override setShadow(shadow: boolean) {
super.setShadow(shadow);
this.applyColour();
this.recomputeAriaAttributes();
this.recomputeAriaContext();
}
/**
@@ -1067,7 +1067,7 @@ export class BlockSvg
for (const child of this.getChildren(false)) {
child.updateDisabled();
}
this.recomputeAriaAttributes();
this.recomputeAriaContext();
}
/**
@@ -1881,6 +1881,11 @@ export class BlockSvg
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
// For full-block fields, we focus the field itself
const fullBlockField = this.getFullBlockField();
if (fullBlockField) {
return fullBlockField.getFocusableElement();
}
return this.pathObject.svgPath;
}
@@ -1891,7 +1896,7 @@ export class BlockSvg
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.recomputeAriaAttributes();
this.recomputeAriaContext();
this.select();
const focusedNode = getFocusManager().getFocusedNode();
if (focusedNode && focusedNode !== this) {
@@ -2001,7 +2006,8 @@ export class BlockSvg
/**
* Updates the ARIA label, role and roledescription for this block.
*/
private recomputeAriaAttributes() {
private recomputeAriaContext() {
if (this.getFullBlockField()) return;
aria.setState(
this.getFocusableElement(),
aria.State.LABEL,
@@ -2018,7 +2024,7 @@ export class BlockSvg
* @returns An accessibility description of this block.
*/
getAriaLabel(verbosity: aria.Verbosity) {
return computeAriaLabel(this, verbosity);
return computeAriaLabel(this, verbosity, false);
}
/**
@@ -2035,7 +2041,7 @@ export class BlockSvg
block = block.getNextBlock();
}
if (count <= 1) {
return this.getAriaLabel(aria.Verbosity.TERSE);
return computeAriaLabel(this, aria.Verbosity.TERSE);
}
const labelTemplate = Msg['BLOCK_LABEL_STACK_BLOCKS'];
+73 -6
View File
@@ -32,6 +32,7 @@ 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';
import * as aria from './utils/aria.js';
import type {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js';
@@ -275,7 +276,6 @@ export abstract class Field<T = any>
`problems with focus: ${block.id}.`,
);
}
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
}
/**
@@ -398,11 +398,7 @@ export abstract class Field<T = any>
// Field has already been initialized once.
return;
}
const id = this.id_;
if (!id) throw new Error('Expected ID to be defined prior to init.');
this.fieldGroup_ = dom.createSvgElement(Svg.G, {
'id': id,
});
this.fieldGroup_ = dom.createSvgElement(Svg.G, {});
if (!this.isVisible()) {
this.fieldGroup_.style.display = 'none';
}
@@ -414,6 +410,15 @@ export abstract class Field<T = any>
this.bindEvents_();
this.initModel();
this.applyColour();
// Since full-block fields can be focused from the workspace's tree,
// they need IDs in the format that the workspace is expecting.
if (this.isFullBlockField()) {
this.id_ = idGenerator.getNextUniqueId();
} else {
this.id_ = `${sourceBlockSvg.id}_field_${idGenerator.getNextUniqueId()}`;
}
this.fieldGroup_.setAttribute('id', this.id_);
}
/**
@@ -1492,6 +1497,68 @@ export abstract class Field<T = any>
this.showEditor();
}
/**
* Recomputes the aria state and label for this field. Fields are generally hidden
* when in blocks in the flyout (except for top-level full-block fields), and
* otherwise set to a role of button (indicating they can be clicked to edit)
* and given the label returned from their `computeAriaLabel` method.
*
* Subclasses can override this in order to change the role or label, but they must
* ensure they keep the correct behavior for fields in flyout blocks.
*
* This method will return a boolean indicating if the element is displayed in the
* aria tree or not. This can be used by subclasses to determine whether or not
* to continue customizing the role and label (hidden elements should not have labels).
*
* @returns true if the element is in the accessibility tree, false if the aria state is hidden
*/
protected recomputeAriaContext(): boolean {
let focusableElement;
try {
focusableElement = this.getFocusableElement();
} catch {
// Just return because the field hasn't been initialized yet.
return false;
}
if (!focusableElement) return false;
if (this.getSourceBlock()?.isInFlyout) {
const isTopLevelFullBlockField =
this.getSourceBlock()?.getFullBlockField() &&
!this.getSourceBlock()?.getParent();
if (!isTopLevelFullBlockField) {
// Fields in the flyout are not generally focusable, so they should
// be hidden. An exception is full-block field blocks that don't have
// parents, since the block itself defers to the field's focusable element.
aria.setState(focusableElement, aria.State.HIDDEN, true);
return false;
} else {
// Top-level full-block fields in the flyout need to have their
// roledescription set. This can't happen in the flyout code because
// the field hasn't been initialized yet then.
// These blocks should also have the rest of the state in this method set.
const roleDescription =
this.getSourceBlock()?.getAriaRoleDescription() ||
Msg['BLOCK_LABEL_VALUE'];
aria.setState(
focusableElement,
aria.State.ROLEDESCRIPTION,
roleDescription,
);
}
}
aria.clearState(focusableElement, aria.State.HIDDEN);
// The button role is intended to indicate to users that the field has an
// editing mode that can be activated.
aria.setRole(focusableElement, aria.Role.BUTTON);
const label = this.computeAriaLabel(true);
aria.setState(focusableElement, aria.State.LABEL, label);
return true;
}
/**
* Subclasses should reimplement this method to construct their Field
* subclass from a JSON arg object.
+6 -8
View File
@@ -267,16 +267,13 @@ export class FieldCheckbox extends Field<CheckboxBool> {
}
/**
* Recomputes the ARIA role and label for this field.
* Customizes the label and sets additional aria state.
*/
protected recomputeAriaContext(): void {
const focusableElement = this.getClickTarget_();
if (!focusableElement) return;
override recomputeAriaContext(): boolean {
const shouldCustomize = super.recomputeAriaContext();
if (!shouldCustomize) return false;
if (this.getSourceBlock()?.isInFlyout) {
aria.setState(focusableElement, aria.State.HIDDEN, true);
return;
}
const focusableElement = this.getFocusableElement();
aria.setState(focusableElement, aria.State.HIDDEN, false);
aria.setRole(focusableElement, aria.Role.CHECKBOX);
@@ -289,6 +286,7 @@ export class FieldCheckbox extends Field<CheckboxBool> {
const label = this.getAriaTypeName();
aria.setState(focusableElement, aria.State.LABEL, label);
return true;
}
}
+6 -14
View File
@@ -918,27 +918,19 @@ export class FieldDropdown extends Field<string> {
}
/**
* Recomputes the ARIA role and label for this field.
* Overrides the default label and sets additional aria state.
*/
protected recomputeAriaContext(): void {
override recomputeAriaContext(): boolean {
const shouldCustomize = super.recomputeAriaContext();
if (!shouldCustomize) return false;
const focusableElement = this.getFocusableElement();
if (!focusableElement) return;
if (this.getSourceBlock()?.isInFlyout) {
aria.setState(focusableElement, aria.State.HIDDEN, true);
return;
}
aria.setState(focusableElement, aria.State.HIDDEN, false);
// The button role is intended to indicate to users that the field has an
// editing mode that can be activated.
aria.setRole(focusableElement, aria.Role.BUTTON);
const label = this.computeAriaLabel(true);
aria.setState(focusableElement, aria.State.LABEL, label);
aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox');
aria.setState(focusableElement, aria.State.EXPANDED, !!this.menu_);
return true;
}
/**
+11 -17
View File
@@ -316,30 +316,24 @@ export class FieldImage extends Field<string> {
}
/**
* Recomputes the ARIA role and label for this field.
* Customizes label and sets additional aria state.
*/
protected recomputeAriaContext(): void {
const focusableElement = this.getClickTarget_();
if (!focusableElement) return;
override recomputeAriaContext(): boolean {
const shouldCustomize = super.recomputeAriaContext();
if (!shouldCustomize) return false;
const isInFlyout = this.getSourceBlock()?.isInFlyout;
if (isInFlyout) {
aria.setState(focusableElement, aria.State.HIDDEN, true);
return;
}
const focusableElement = this.getFocusableElement();
aria.setState(focusableElement, aria.State.HIDDEN, false);
// The button role is intended to indicate to users that the field has an
// editing mode that can be activated. The presentation role is used to
// prevent screen readers from reading the content or its descendants.
// Only clickable image fields are navigable.
aria.setRole(
focusableElement,
this.isClickable() ? aria.Role.BUTTON : aria.Role.PRESENTATION,
);
const label = this.computeAriaLabel(true);
aria.setState(focusableElement, aria.State.LABEL, label);
if (!this.isClickable()) {
aria.setRole(focusableElement, aria.Role.PRESENTATION);
aria.clearState(focusableElement, aria.State.LABEL);
return false;
}
return true;
}
}
+7 -17
View File
@@ -837,29 +837,19 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
}
/**
* Recomputes the ARIA role and label for this field.
* Customizes the label for this field to include "editable" if it applies.
*/
protected recomputeAriaContext(): void {
const focusableElement = this.getClickTarget_();
if (!focusableElement) return;
if (this.getSourceBlock()?.isInFlyout) {
aria.setState(focusableElement, aria.State.HIDDEN, true);
return;
}
aria.setState(focusableElement, aria.State.HIDDEN, false);
// The button role is intended to indicate to users that the field has an
// editing mode that can be activated.
aria.setRole(focusableElement, aria.Role.BUTTON);
override recomputeAriaContext(): boolean {
const shouldCustomize = super.recomputeAriaContext();
if (!shouldCustomize) return false;
const focusableElement = this.getFocusableElement();
let label = this.computeAriaLabel(true);
if (this.isCurrentlyEditable?.()) {
if (this.isCurrentlyEditable() && !this.getSourceBlock()?.isInFlyout) {
label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label);
}
aria.setState(focusableElement, aria.State.LABEL, label);
return true;
}
}
+1 -18
View File
@@ -425,25 +425,8 @@ suite('ARIA', function () {
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.getFocusableElement(),
Blockly.utils.aria.State.LABEL,
);
assert.notInclude(label, 'replaceable');
text.setShadow(true);
label = Blockly.utils.aria.getState(
text.getFocusableElement(),
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 block = this.makeBlock('logic_boolean');
const label = Blockly.utils.aria.getState(
block.getFocusableElement(),
Blockly.utils.aria.State.LABEL,
@@ -341,10 +341,6 @@ suite('Dropdown Fields', function () {
this.focusableElement = this.field.getFocusableElement();
});
test('Block has field type name in ARIA label', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'dropdown:');
});
test('Field has field type name in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.include(fieldLabel, 'dropdown:');
@@ -362,19 +362,11 @@ suite('Image Fields', function () {
this.block.render();
this.focusableElement = this.field.getFocusableElement();
});
test('Block has field type name in ARIA label', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'image:');
});
test('Field has field type name in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.include(fieldLabel, 'image:');
});
test('Block has image alt text in ARIA label', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, this.field.altText);
});
test('Focusable element has role of presentation', function () {
test('Focusable element has role of presentation', function () {
const role = this.focusableElement.getAttribute('role');
assert.equal(role, 'presentation');
});
@@ -387,6 +379,16 @@ suite('Image Fields', function () {
});
});
suite('Image with click handler', function () {
test('Field has field type name in ARIA label', function () {
const block = this.workspace.newBlock('test_images_clickhandler');
const field = block.getField('IMAGE');
block.initSvg();
block.render();
const focusableElement = field.getFocusableElement();
const fieldLabel = focusableElement.getAttribute('aria-label');
assert.include(fieldLabel, 'image:');
});
test('Focusable element has role of button', function () {
const block = this.workspace.newBlock('test_images_clickhandler');
const field = block.getField('IMAGE');
@@ -514,10 +514,6 @@ suite('Number Fields', function () {
this.focusableElement = this.field.getClickTarget_();
});
test('Block has field type name in ARIA label', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'number:');
});
test('Field has field type name in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.include(fieldLabel, 'number:');
@@ -608,10 +608,6 @@ suite('Text Input Fields', function () {
this.focusableElement = this.field.getClickTarget_();
});
test('Block has field type name in ARIA label', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'text:');
});
test('Field has field type name in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.include(fieldLabel, 'text:');
@@ -661,9 +661,9 @@ suite('Variable Fields', function () {
this.focusableElement = this.field.getFocusableElement();
});
test('Block has dropdown field type name and "Variable" qualifier in ARIA label', function () {
test('Block has "Variable" qualifier in ARIA label', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'dropdown:');
assert.include(blockLabel, 'Variable');
});
test('Field has dropdown field type name and "Variable" qualifier in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
+4 -3
View File
@@ -310,7 +310,8 @@ suite('Inputs', function () {
},
]);
});
test('Set input ARIA Label Provider', function () {
// Temporarily skipping while we debate custom inputs aria behavior
test.skip('Set input ARIA Label Provider', function () {
const customLabel = 'custom ARIA label';
// Using a text input as it will return a default ARIA label
this.block
@@ -323,7 +324,7 @@ suite('Inputs', function () {
assert.include(label, customLabel);
assert.notInclude(label, 'text');
});
test('Set input ARIA Label Provider from JSON', function () {
test.skip('Set input ARIA Label Provider from JSON', function () {
const customLabel = 'custom ARIA label';
Blockly.defineBlocksWithJsonArray([
{
@@ -349,7 +350,7 @@ suite('Inputs', function () {
assert.include(label, customLabel);
});
test('Set input ARIA Label Provider to null', function () {
test.skip('Set input ARIA Label Provider to null', function () {
const blockA = createRenderedBlock(this.workspace, 'row_block');
const blockB = createRenderedBlock(this.workspace, 'row_block');
@@ -1273,15 +1273,32 @@ suite('Keyboard-driven movement', function () {
cancelMove(this.workspace);
});
test('disambiguates with custom input labels', function () {
const ifBlock = this.workspace.newBlock('controls_if');
ifBlock.initSvg();
ifBlock.elseifCount_ = 1;
ifBlock.elseCount_ = 1;
ifBlock.updateShape_();
ifBlock.render();
test('disambiguates with custom input labels around blocks', function () {
const json = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'draw_emoji',
'id': 'drawBlock',
'x': -37,
'y': 0,
},
{
'type': 'controls_if',
'id': 'ifBlock',
'x': -37,
'y': 100,
'extraState': {
'elseIfCount': 1,
},
},
],
},
};
Blockly.serialization.workspaces.load(json, this.workspace);
const ifBlock = this.workspace.getBlockById('ifBlock');
ifBlock.getInput('DO1').setAriaLabelProvider('custom else if branch');
this.workspace.cleanUp();
Blockly.getFocusManager().focusNode(ifBlock);
startMove(this.workspace); // on workspace
@@ -1293,13 +1310,46 @@ suite('Keyboard-driven movement', function () {
['else if, do'],
);
cancelMove(this.workspace);
Blockly.getFocusManager().focusNode(this.block1);
});
test('disambiguates with custom input labels inside blocks', function () {
const json = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'draw_emoji',
'id': 'drawBlock',
'x': -37,
'y': 0,
},
{
'type': 'controls_if',
'id': 'ifBlock',
'x': -37,
'y': 100,
'extraState': {
'elseIfCount': 1,
},
},
],
},
};
Blockly.serialization.workspaces.load(json, this.workspace);
const ifBlock = this.workspace.getBlockById('ifBlock');
ifBlock.getInput('DO1').setAriaLabelProvider('custom else if branch');
const drawBlock = this.workspace.getBlockById('drawBlock');
Blockly.getFocusManager().focusNode(drawBlock);
this.clock.tick(10);
this.moveAndAssert(
startMove,
['Moving', 'inside', 'custom else if branch'],
['else if, do'],
);
startMove(this.workspace);
moveRight(this.workspace); // before if block
moveRight(this.workspace); // inside first do
this.moveAndAssert(moveRight, [
'Moving',
'inside',
'custom else if branch',
]);
cancelMove(this.workspace);
});
test('disambiguates between multiple value inputs', function () {
@@ -1358,7 +1408,7 @@ suite('Keyboard-driven movement', function () {
suite('of bubbles', function () {
setup(async function () {
const commentBlock = this.workspace.newBlock('logic_boolean');
const commentBlock = this.workspace.newBlock('logic_compare');
commentBlock.setCommentText('Hello world');
const icon = commentBlock.getIcon(Blockly.icons.IconType.COMMENT);
await icon.setBubbleVisible(true);