diff --git a/packages/blockly/core/block.ts b/packages/blockly/core/block.ts index a47eb1e5430..4690229d439 100644 --- a/packages/blockly/core/block.ts +++ b/packages/blockly/core/block.ts @@ -2165,6 +2165,9 @@ export class Block { input.setAlign(alignment); } } + if (element['ariaLabelText']) { + input.setAriaLabelProvider(element['ariaLabelText']); + } return input; } diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 5e02f3fc9c4..aa9cb6db799 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -204,7 +204,11 @@ export function getInputLabels( ): string[] { return block.inputList .filter((input) => input.isVisible()) - .map((input) => input.getLabel(verbosity)); + .map((input) => + input.getAriaLabelText() !== null + ? input.getAriaLabelText()! + : input.getLabel(verbosity), + ); } /** diff --git a/packages/blockly/core/inputs/input.ts b/packages/blockly/core/inputs/input.ts index 86171316e4d..96589274496 100644 --- a/packages/blockly/core/inputs/input.ts +++ b/packages/blockly/core/inputs/input.ts @@ -23,9 +23,17 @@ 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 * as parsing from '../utils/parsing.js'; import {Align} from './align.js'; import {inputTypes} from './input_types.js'; +/** + * Represents a string or a function that returns a string which can be used as a + * custom ARIA string to represent an Input, or null if the default fallback should + * be used. See setAriaLabelProvider for more context. + */ +export type AriaLabelProvider = ((input: Input) => string | null) | string; + /** Class for an input with optional fields. */ export class Input { fieldRow: Field[] = []; @@ -35,6 +43,9 @@ export class Input { /** Is the input visible? */ private visible = true; + /** The AriaLabelProvider */ + private ariaLabelProvider: AriaLabelProvider | null = null; + public readonly type: inputTypes = inputTypes.CUSTOM; public connection: Connection | null = null; @@ -273,6 +284,38 @@ export class Input { } } + /** + * Sets a custom ARIA label provider for this input, or null if it should be reset + * to use the default method. + * + * Inputs do not compute ARIA contexts directly, so the set provider will be used + * in select cases when the Input needs to be represented (such as for parts of a + * block label or for connections). Note that overriding this provider will not + * recompute any already constructed ARIA labels, and it cannot be assumed that the + * provider will be called any particular number of times during label + * recomputation. As such, implementations should make sure to provide a + * deterministic and idempotent ARIA representation each time the provider is + * called for a given input. It's also fine to reuse providers across multiple + * Input implementations. + */ + setAriaLabelProvider(provider: AriaLabelProvider | null) { + this.ariaLabelProvider = provider; + } + + /** + * Returns the string from the custom ARIA label provider set, or null if the default label (from the field row) should + * be used. See setAriaLabelProvider for more context. + */ + getAriaLabelText(): string | null { + if (!this.ariaLabelProvider) { + return null; + } else if (typeof this.ariaLabelProvider === 'string') { + return parsing.replaceMessageReferences(this.ariaLabelProvider); + } else { + return this.ariaLabelProvider(this); + } + } + /** * Initializes the fields on this input for a headless block. * diff --git a/packages/blockly/tests/mocha/input_test.js b/packages/blockly/tests/mocha/input_test.js index dfa30858e0e..d06444ec263 100644 --- a/packages/blockly/tests/mocha/input_test.js +++ b/packages/blockly/tests/mocha/input_test.js @@ -5,6 +5,7 @@ */ import {assert} from '../../node_modules/chai/index.js'; +import {createRenderedBlock} from './test_helpers/block_definitions.js'; import { sharedTestSetup, sharedTestTeardown, @@ -293,4 +294,79 @@ suite('Inputs', function () { assert.deepEqual(this.dummy.fieldRow, [this.b, this.c]); }); }); + suite('ARIA', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'row_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'INPUT', + }, + ], + 'output': null, + }, + ]); + }); + test('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 + .appendValueInput('NAME') + .appendField(new Blockly.FieldTextInput('text'), 'NAME') + .setAriaLabelProvider((input) => customLabel); + + const label = this.block.getAriaLabel(); + + assert.include(label, customLabel); + assert.notInclude(label, 'text'); + }); + test('Set input ARIA Label Provider from JSON', function () { + const customLabel = 'custom ARIA label'; + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'input_aria_block', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'text', + }, + { + 'type': 'input_value', + 'name': 'NAME', + 'ariaLabelText': customLabel, + }, + ], + }, + ]); + + this.block = this.workspace.newBlock('input_aria_block'); + const label = this.block.getAriaLabel(); + + assert.include(label, customLabel); + }); + test('Set input ARIA Label Provider to null', function () { + const blockA = createRenderedBlock(this.workspace, 'row_block'); + const blockB = createRenderedBlock(this.workspace, 'row_block'); + + blockA + .appendValueInput('NAME') + .appendField(new Blockly.FieldTextInput('text'), 'NAME') + .setAriaLabelProvider(null); + blockB + .appendValueInput('NAME') + .appendField(new Blockly.FieldTextInput('text'), 'NAME'); + + const labelA = blockA.getAriaLabel(); + const labelB = blockB.getAriaLabel(); + + // The label should be the same between a block created with a null + // AriaLabelProvider and without setting the provider (the default label) + assert.equal(labelA, labelB); + }); + }); });