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
23 changes: 16 additions & 7 deletions packages/blockly/core/block_aria_composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function computeAriaLabel(
return [
verbosity >= Verbosity.STANDARD && getBeginStackLabel(block),
getParentInputLabel(block),
...getInputLabels(block),
...getInputLabels(block, verbosity),
verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block),
verbosity >= Verbosity.STANDARD && getDisabledLabel(block),
verbosity >= Verbosity.STANDARD && getCollapsedLabel(block),
Expand Down Expand Up @@ -111,15 +111,17 @@ export function configureAriaRole(block: BlockSvg) {
export function computeFieldRowLabel(
input: Input,
lookback: boolean,
verbosity = Verbosity.STANDARD,
): string[] {
const includeTypeInfo = verbosity >= Verbosity.STANDARD;
const fieldRowLabel = input.fieldRow
.filter((field) => field.isVisible())
.map((field) => field.computeAriaLabel(true));
.map((field) => field.computeAriaLabel(includeTypeInfo));
if (!fieldRowLabel.length && lookback) {
const inputs = input.getSourceBlock().inputList;
const index = inputs.indexOf(input);
if (index > 0) {
return computeFieldRowLabel(inputs[index - 1], lookback);
return computeFieldRowLabel(inputs[index - 1], lookback, verbosity);
}
}
return fieldRowLabel;
Expand Down Expand Up @@ -186,10 +188,13 @@ 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.
*/
export function getInputLabels(block: BlockSvg): string[] {
export function getInputLabels(
block: BlockSvg,
verbosity = Verbosity.STANDARD,
): string[] {
return block.inputList
.filter((input) => input.isVisible())
.map((input) => input.getLabel());
.map((input) => input.getLabel(verbosity));
}

/**
Expand All @@ -208,7 +213,11 @@ export function getInputLabels(block: BlockSvg): string[] {
* @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[] {
export function getInputLabelsSubset(
block: BlockSvg,
input: Input,
verbosity = Verbosity.STANDARD,
): string[] {
const inputIndex = block.inputList.indexOf(input);
if (inputIndex === -1) {
throw new Error(
Expand All @@ -226,7 +235,7 @@ export function getInputLabelsSubset(block: BlockSvg, input: Input): string[] {
.filter((input) => input.isVisible())
.map(
(input) =>
input.getLabel() ||
input.getLabel(verbosity) ||
Msg['INPUT_LABEL_INDEX'].replace(
'%1',
(input.getIndex() + 1).toString(),
Expand Down
54 changes: 54 additions & 0 deletions packages/blockly/core/field_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyInputField');
}
this.recomputeAriaContext();
}

override isFullBlockField(): boolean {
Expand Down Expand Up @@ -224,6 +225,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
);
}
}
this.recomputeAriaContext();
}

/**
Expand All @@ -238,6 +240,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
this.isDirty_ = true;
this.isTextValid_ = true;
this.value_ = newValue;
this.recomputeAriaContext();
}

/**
Expand Down Expand Up @@ -807,6 +810,57 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
protected getValueFromEditorText_(text: string): AnyDuringMigration {
return text;
}

/**
* Gets an ARIA-friendly label representation of this field's type.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's type.
*
* @returns An ARIA representation of the field's type or a default if it is
* unspecified.
*/
override getAriaTypeName(): string | null {
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_INPUT'];
}

/**
* Gets an ARIA-friendly label representation of this field's value.
*
* 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 text.
*/
override getAriaValue(): string | null {
return this.getText() || Msg['FIELD_LABEL_EMPTY'];
}

/**
* Recomputes the ARIA role and label for this field.
*/
private 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);

let label = this.computeAriaLabel(false);

if (this.isCurrentlyEditable?.()) {
label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label);
}

aria.setState(focusableElement, aria.State.LABEL, label);
}
}

/**
Expand Down
9 changes: 6 additions & 3 deletions packages/blockly/core/inputs/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {ConnectionType} from '../connection_type.js';
import type {Field} from '../field.js';
import * as fieldRegistry from '../field_registry.js';
import {RenderedConnection} from '../rendered_connection.js';
import {Verbosity} from '../utils/aria.js';
import {Align} from './align.js';
import {inputTypes} from './input_types.js';

Expand Down Expand Up @@ -356,15 +357,17 @@ export class Input {
*
* @internal
*/
getLabel(): string {
getLabel(verbosity = Verbosity.STANDARD): string {
if (!this.isVisible()) return '';

const labels = computeFieldRowLabel(this, false);
const labels = computeFieldRowLabel(this, false, verbosity);

if (this.connection?.type === ConnectionType.INPUT_VALUE) {
const childBlock = this.connection.targetBlock();
if (childBlock && !childBlock.isInsertionMarker()) {
labels.push(getInputLabels(childBlock as BlockSvg).join(' '));
labels.push(
getInputLabels(childBlock as BlockSvg, verbosity).join(' '),
);
}
}
return labels.join(' ');
Expand Down
6 changes: 4 additions & 2 deletions packages/blockly/msg/json/en.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2026-04-21 12:36:42.927221",
"lastupdated": "2026-04-21 16:21:15.987859",
"locale": "en",
"messagedocumentation" : "qqq"
},
Expand Down Expand Up @@ -468,5 +468,7 @@
"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"
"FIELD_LABEL_EMPTY": "empty",
"ARIA_TYPE_FIELD_INPUT": "input field",
"FIELD_LABEL_EDIT_PREFIX": "Edit %1"
}
6 changes: 4 additions & 2 deletions packages/blockly/msg/json/qqq.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"@metadata": {
"@metadata": {
"authors": [
"Ajeje Brazorf",
"Amire80",
Expand Down Expand Up @@ -475,5 +475,7 @@
"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."
"FIELD_LABEL_EMPTY": "Label for an empty field, used by screen readers to identify fields that have no content.",
"ARIA_TYPE_FIELD_INPUT": "ARIA type name for an input field, used by screen readers to identify the type of field.",
"FIELD_LABEL_EDIT_PREFIX": "Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. \n\nParameters:\n* %1 - the label of the field's value \n\nExamples:\n* 'Edit 5'\n* 'Edit item'"
}
10 changes: 9 additions & 1 deletion packages/blockly/msg/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -1881,4 +1881,12 @@ Blockly.Msg.ANNOUNCE_MOVE_TO = 'moving %1 %2 to %3 %4';
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';
Blockly.Msg.FIELD_LABEL_EMPTY = 'empty';
/** @type {string} */
/// ARIA type name for an input field, used by screen readers to identify the type of field.
Blockly.Msg.ARIA_TYPE_FIELD_INPUT = 'input field';
/** @type {string} */
/// Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value.
/// \n\nParameters:\n* %1 - the label of the field's value
/// \n\nExamples:\n* "Edit 5"\n* "Edit item"
Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1';
46 changes: 46 additions & 0 deletions packages/blockly/tests/mocha/field_number_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -502,4 +502,50 @@ suite('Number Fields', function () {
this.assertValue(1.7976931348623157e308);
});
});
suite('ARIA', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
const block = this.workspace.newBlock('math_number');
this.field = block.getField('NUM');
block.initSvg();
block.render();

this.focusableElement = this.field.getClickTarget_();
});
test('Focusable element has role of button', function () {
const role = this.focusableElement.getAttribute('role');
assert.equal(role, 'button');
});
test('Hidden when in a flyout', function () {
this.field.getSourceBlock().isInFlyout = true;
// Force recompute of ARIA label.
this.field.setValue(this.field.getValue());
const ariaHidden = this.focusableElement.getAttribute('aria-hidden');
assert.equal(ariaHidden, 'true');
});
test('Has an ARIA label by default', function () {
const label = this.focusableElement.getAttribute('aria-label');
assert.isTrue(label.includes('0'));
});
test('Has Edit prefix if editable', function () {
const label = this.focusableElement.getAttribute('aria-label');
assert.isTrue(label.includes('Edit'));
});
test('Does not have Edit prefix if not editable', function () {
this.field.EDITABLE = false;
// Force recompute of ARIA label.
this.field.setValue(this.field.getValue());
const label = this.focusableElement.getAttribute('aria-label');
assert.isFalse(label.includes('Edit'));
});
test('setValue updates ARIA label', function () {
const initialLabel = this.focusableElement.getAttribute('aria-label');
assert.isTrue(initialLabel.includes('0'));
this.field.setValue(1);
const updatedLabel = this.focusableElement.getAttribute('aria-label');
assert.isTrue(updatedLabel.includes('1'));
});
});
});
47 changes: 47 additions & 0 deletions packages/blockly/tests/mocha/field_textinput_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -592,4 +592,51 @@ suite('Text Input Fields', function () {
});
});
});

suite('ARIA', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
const block = this.workspace.newBlock('text');
this.field = block.getField('TEXT');
block.initSvg();
block.render();

this.focusableElement = this.field.getClickTarget_();
});
test('Focusable element has role of button', function () {
const role = this.focusableElement.getAttribute('role');
assert.equal(role, 'button');
});
test('Hidden when in a flyout', function () {
this.field.getSourceBlock().isInFlyout = true;
// Force recompute of ARIA label.
this.field.setValue(this.field.getValue());
const ariaHidden = this.focusableElement.getAttribute('aria-hidden');
assert.equal(ariaHidden, 'true');
});
test('Has placeholder ARIA label by default', function () {
const label = this.focusableElement.getAttribute('aria-label');
assert.isTrue(label.includes('empty'));
});
test('Has Edit prefix if editable', function () {
const label = this.focusableElement.getAttribute('aria-label');
assert.isTrue(label.includes('Edit'));
});
test('Does not have Edit prefix if not editable', function () {
this.field.EDITABLE = false;
// Force recompute of ARIA label.
this.field.setValue(this.field.getValue());
const label = this.focusableElement.getAttribute('aria-label');
assert.isFalse(label.includes('Edit'));
});
test('setValue updates ARIA label', function () {
const initialLabel = this.focusableElement.getAttribute('aria-label');
assert.isTrue(initialLabel.includes('empty'));
this.field.setValue('new value');
const updatedLabel = this.focusableElement.getAttribute('aria-label');
assert.isTrue(updatedLabel.includes('new value'));
});
});
});