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..5f024bdab99 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,57 @@ export abstract class FieldInput 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); + } } /** 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..2f5709daedc 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,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" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 5a1472db20f..bc96e9f732b 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,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'" } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 8c8271033e1..f1705750dae 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -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'; \ No newline at end of file +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'; \ 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')); + }); + }); });