feat: FieldInput ARIA (#9744)

* feat: `FieldInput' ARIA

* chore: update tsdocs

* chore: lint fix

* fix: use aria util for setting role

* fix: use single empty field message
This commit is contained in:
Michael Harvey
2026-04-21 17:23:18 -04:00
committed by GitHub
parent 36ca80aa30
commit 9c0846bfab
8 changed files with 186 additions and 15 deletions
+16 -7
View File
@@ -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),
@@ -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;
@@ -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));
}
/**
@@ -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(
@@ -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(),
+54
View File
@@ -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 {
@@ -224,6 +225,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
);
}
}
this.recomputeAriaContext();
}
/**
@@ -238,6 +240,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
this.isDirty_ = true;
this.isTextValid_ = true;
this.value_ = newValue;
this.recomputeAriaContext();
}
/**
@@ -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);
}
}
/**
+6 -3
View File
@@ -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';
@@ -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(' ');
+4 -2
View File
@@ -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"
},
@@ -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"
}
+4 -2
View File
@@ -1,5 +1,5 @@
{
"@metadata": {
"@metadata": {
"authors": [
"Ajeje Brazorf",
"Amire80",
@@ -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'"
}
+9 -1
View File
@@ -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';
@@ -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'));
});
});
});
@@ -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'));
});
});
});