From 4bfa893d933a08b7a08fd90b495daf03c29ff96f Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:24:26 -0400 Subject: [PATCH 1/5] feat: `FieldInput' ARIA --- packages/blockly/core/block_aria_composer.ts | 23 ++++++--- packages/blockly/core/field_input.ts | 43 +++++++++++++++++ packages/blockly/core/inputs/input.ts | 9 ++-- packages/blockly/msg/json/en.json | 7 ++- packages/blockly/msg/json/qqq.json | 7 ++- packages/blockly/msg/messages.js | 13 ++++- .../blockly/tests/mocha/field_number_test.js | 46 ++++++++++++++++++ .../tests/mocha/field_textinput_test.js | 47 +++++++++++++++++++ 8 files changed, 180 insertions(+), 15 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 92551230739..256a6853429 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -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(), diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index ac3717b5957..0c1d10b131d 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -175,6 +175,7 @@ export abstract class FieldInput extends Field< if (this.fieldGroup_) { dom.addClass(this.fieldGroup_, 'blocklyInputField'); } + this.recomputeAriaContext(); } override isFullBlockField(): boolean { @@ -224,6 +225,7 @@ export abstract class FieldInput extends Field< ); } } + this.recomputeAriaContext(); } /** @@ -238,6 +240,7 @@ export abstract class FieldInput extends Field< this.isDirty_ = true; this.isTextValid_ = true; this.value_ = newValue; + this.recomputeAriaContext(); } /** @@ -807,6 +810,46 @@ export abstract class FieldInput extends Field< protected getValueFromEditorText_(text: string): AnyDuringMigration { return text; } + + /** + * Provide a default type if none is specified. + */ + override getAriaTypeName(): string | null { + return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_INPUT']; + } + + /** + * Text inputs should always expose a meaningful value, even when empty. + */ + override getAriaValue(): string | null { + return this.getText() || Msg['FIELD_LABEL_EMPTY_INPUT']; + } + + /** + * 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. + focusableElement.setAttribute('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); + } } /** diff --git a/packages/blockly/core/inputs/input.ts b/packages/blockly/core/inputs/input.ts index 8eb27387c0f..86171316e4d 100644 --- a/packages/blockly/core/inputs/input.ts +++ b/packages/blockly/core/inputs/input.ts @@ -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(' '); diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 0812fa3d493..172c09eecf6 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-21 12:36:42.927221", + "lastupdated": "2026-04-21 16:21:15.987859", "locale": "en", "messagedocumentation" : "qqq" }, @@ -468,5 +468,8 @@ "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", + "FIELD_LABEL_EMPTY_INPUT": "empty", + "ARIA_TYPE_FIELD_INPUT": "input field", + "FIELD_LABEL_EDIT_PREFIX": "Edit %1" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 5a1472db20f..749a04a467a 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -1,5 +1,5 @@ { - "@metadata": { + "@metadata": { "authors": [ "Ajeje Brazorf", "Amire80", @@ -475,5 +475,8 @@ "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.", + "FIELD_LABEL_EMPTY_INPUT": "Label for an empty input field, used by screen readers to identify inputs 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'" } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 8c8271033e1..2bd78a3b953 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1881,4 +1881,15 @@ 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'; \ No newline at end of file +Blockly.Msg.FIELD_LABEL_EMPTY = 'empty'; +/** @type {string} */ +/// Label for an empty input field, used by screen readers to identify inputs that have no content. +Blockly.Msg.FIELD_LABEL_EMPTY_INPUT = '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'; \ No newline at end of file diff --git a/packages/blockly/tests/mocha/field_number_test.js b/packages/blockly/tests/mocha/field_number_test.js index 3c12fed820d..692633bfa3d 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -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')); + }); + }); }); diff --git a/packages/blockly/tests/mocha/field_textinput_test.js b/packages/blockly/tests/mocha/field_textinput_test.js index 0af0efbabd8..0ab0c745296 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -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')); + }); + }); }); From 78ed16bac08662b917aba7519d9c7de9555f8cf7 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:35:01 -0400 Subject: [PATCH 2/5] chore: update tsdocs --- packages/blockly/core/field_input.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index 0c1d10b131d..6184679cafe 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -811,15 +811,26 @@ export abstract class FieldInput extends Field< return text; } - /** - * Provide a default type if none is specified. + /** + * 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']; } - /** - * Text inputs should always expose a meaningful value, even when empty. + /** + * 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_INPUT']; From 7afe3e3414dead436f598e58a9f940f37ed9486c Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:37:18 -0400 Subject: [PATCH 3/5] chore: lint fix --- packages/blockly/core/field_input.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index 6184679cafe..d54af7aa978 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -811,7 +811,7 @@ export abstract class FieldInput extends Field< return text; } - /** + /** * Gets an ARIA-friendly label representation of this field's type. * * Implementations are responsible for, and encouraged to, return a localized @@ -824,7 +824,7 @@ export abstract class FieldInput extends Field< 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 From 3988631babc39ec18af5e0efd712eaebeb3dfff4 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:53:31 -0400 Subject: [PATCH 4/5] fix: use aria util for setting role --- packages/blockly/core/field_input.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index d54af7aa978..b995a163e92 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -851,7 +851,7 @@ export abstract class FieldInput extends Field< 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. - focusableElement.setAttribute('role', 'button'); + aria.setRole(focusableElement, aria.Role.BUTTON); let label = this.computeAriaLabel(false); From 7d7b4a44253b0bf21fa2de842785f0cdb9eea57d Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:08:38 -0400 Subject: [PATCH 5/5] fix: use single empty field message --- packages/blockly/core/field_input.ts | 2 +- packages/blockly/msg/json/en.json | 1 - packages/blockly/msg/json/qqq.json | 1 - packages/blockly/msg/messages.js | 3 --- 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index b995a163e92..5f024bdab99 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -833,7 +833,7 @@ export abstract class FieldInput extends Field< * @returns An ARIA representation of the field's text. */ override getAriaValue(): string | null { - return this.getText() || Msg['FIELD_LABEL_EMPTY_INPUT']; + return this.getText() || Msg['FIELD_LABEL_EMPTY']; } /** diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 172c09eecf6..2f5709daedc 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -469,7 +469,6 @@ "ANNOUNCE_MOVE_TO": "moving %1 %2 to %3 %4", "ANNOUNCE_MOVE_CANCELED": "Canceled movement", "FIELD_LABEL_EMPTY": "empty", - "FIELD_LABEL_EMPTY_INPUT": "empty", "ARIA_TYPE_FIELD_INPUT": "input field", "FIELD_LABEL_EDIT_PREFIX": "Edit %1" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 749a04a467a..bc96e9f732b 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -476,7 +476,6 @@ "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_INPUT": "Label for an empty input field, used by screen readers to identify inputs 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'" } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 2bd78a3b953..f1705750dae 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1883,9 +1883,6 @@ Blockly.Msg.ANNOUNCE_MOVE_CANCELED = 'Canceled movement'; /// Label for an empty field, used by screen readers to identify fields that have no content. Blockly.Msg.FIELD_LABEL_EMPTY = 'empty'; /** @type {string} */ -/// Label for an empty input field, used by screen readers to identify inputs that have no content. -Blockly.Msg.FIELD_LABEL_EMPTY_INPUT = '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} */