From 771f9eabe1f8f882f28546dfe149f8b17048288e Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Thu, 30 Apr 2026 12:43:06 -0400 Subject: [PATCH] feat: allow setting custom role description for blocks (#9777) * feat: allow setting custom role description for blocks * fix: update test --- packages/blockly/core/block.ts | 35 +++++++++++++++++++ packages/blockly/core/block_aria_composer.ts | 10 ++++-- .../interfaces/i_json_block_definition.ts | 1 + packages/blockly/msg/json/en.json | 4 +-- packages/blockly/msg/json/qqq.json | 4 +-- packages/blockly/msg/messages.js | 4 ++- packages/blockly/tests/mocha/aria_test.js | 2 +- .../blockly/tests/mocha/block_json_test.js | 32 +++++++++++++++++ 8 files changed, 83 insertions(+), 9 deletions(-) diff --git a/packages/blockly/core/block.ts b/packages/blockly/core/block.ts index a9b767dab..a47eb1e54 100644 --- a/packages/blockly/core/block.ts +++ b/packages/blockly/core/block.ts @@ -53,6 +53,7 @@ import {Coordinate} from './utils/coordinate.js'; import * as deprecation from './utils/deprecation.js'; import * as idGenerator from './utils/idgenerator.js'; import * as parsing from './utils/parsing.js'; +import {replaceMessageReferences} from './utils/parsing.js'; import {Size} from './utils/size.js'; import type {Workspace} from './workspace.js'; @@ -243,6 +244,9 @@ export class Block { inputsInlineDefault?: boolean; workspace: Workspace; + /** A custom provider for generating the aria role description for this block. */ + private ariaRoleDescriptionProvider: string | (() => string) | undefined; + /** * @param workspace The block's workspace. * @param prototypeName Name of the language object containing type-specific @@ -1548,6 +1552,32 @@ export class Block { } } + /** + * Set a custom aria role description provider for this block. If not set, + * uses a default provider based on the block's properties (e.g. whether it has + * inputs, outputs, etc.). + * + * @param description The description or function to provide the description. + * If a string, we'll replace message references in the string, e.g. + * `%{BKY_CUSTOM_MESSAGE}` will be replaced with the value of + * `Blockly.Msg['CUSTOM_MESSAGE']`.}' + */ + setAriaRoleDescriptionProvider(description: string | (() => string)) { + this.ariaRoleDescriptionProvider = description; + } + + /** + * @returns The custom string to use as the role description for this block, + * or undefined if no custom description is set. + */ + getAriaRoleDescription(): string | undefined { + if (!this.ariaRoleDescriptionProvider) return undefined; + if (typeof this.ariaRoleDescriptionProvider === 'function') { + return this.ariaRoleDescriptionProvider(); + } + return replaceMessageReferences(this.ariaRoleDescriptionProvider); + } + /** * Create a human-readable text representation of this block and any children. * @@ -1802,6 +1832,11 @@ export class Block { const localizedValue = parsing.replaceMessageReferences(rawValue); this.setHelpUrl(localizedValue); } + + if (json['ariaRoleDescription'] !== undefined) { + this.setAriaRoleDescriptionProvider(json['ariaRoleDescription']); + } + if (typeof json['extensions'] === 'string') { console.warn( warningPrefix + diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index c2b661163..5e02f3fc9 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -87,13 +87,17 @@ export function configureAriaRole(block: BlockSvg) { setRole(focusableElement, Role.FIGURE); } - let roleDescription = Msg['BLOCK_LABEL_STATEMENT']; - if (block.statementInputCount) { + let roleDescription; + const customDescription = block.getAriaRoleDescription(); + if (customDescription) { + roleDescription = customDescription; + } else if (block.statementInputCount) { roleDescription = Msg['BLOCK_LABEL_CONTAINER']; } else if (block.outputConnection) { roleDescription = Msg['BLOCK_LABEL_VALUE']; + } else { + roleDescription = Msg['BLOCK_LABEL_STATEMENT']; } - setState(focusableElement, State.ROLEDESCRIPTION, roleDescription); } diff --git a/packages/blockly/core/interfaces/i_json_block_definition.ts b/packages/blockly/core/interfaces/i_json_block_definition.ts index 0b3d57676..0c2cef235 100644 --- a/packages/blockly/core/interfaces/i_json_block_definition.ts +++ b/packages/blockly/core/interfaces/i_json_block_definition.ts @@ -42,6 +42,7 @@ export interface JsonBlockDefinition { inputsInline?: boolean; tooltip?: string; helpUrl?: string; + ariaRoleDescription?: string; extensions?: string[]; mutator?: string; enableContextMenu?: boolean; diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index cbe2bab03..37091514d 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-29 12:42:30.774691", + "lastupdated": "2026-04-29 16:09:43.926632", "locale": "en", "messagedocumentation" : "qqq" }, @@ -474,7 +474,7 @@ "BLOCK_LABEL_REPLACEABLE": "replaceable", "BLOCK_LABEL_HAS_INPUT": "has input", "BLOCK_LABEL_HAS_INPUTS": "has inputs", - "BLOCK_LABEL_STATEMENT": "statement", + "BLOCK_LABEL_STATEMENT": "command", "BLOCK_LABEL_CONTAINER": "container", "BLOCK_LABEL_VALUE": "value", "BLOCK_LABEL_STACK_BLOCKS": "%1 stack blocks", diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index ef75e8a0e..a27280113 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", @@ -482,7 +482,7 @@ "BLOCK_LABEL_REPLACEABLE": "Part of an accessibility label for a block that indicates that it is replaceable, i.e. that it is a shadow block.", "BLOCK_LABEL_HAS_INPUT": "Part of an accessibility label for a block that indicates that it has a single input.", "BLOCK_LABEL_HAS_INPUTS": "Part of an accessibility label for a block that indicates that it has more than one input.", - "BLOCK_LABEL_STATEMENT": "Part of an accessibility label for a block that indicates that it is a statement block, i.e. that it has a next or previous connection.", + "BLOCK_LABEL_STATEMENT": "Part of an accessibility label for a block that indicates that it is a statement block, i.e. that it has a next or previous connection. 'command' here is used in the sense of a computer command, or a command block in Scratch.", "BLOCK_LABEL_CONTAINER": "Part of an accessibility label for a block that indicates that it is a container block, i.e. that it has one or more statement inputs.", "BLOCK_LABEL_VALUE": "Part of an accessibility label for a block that indicates that it is a value block, i.e. that it has an output connection.", "BLOCK_LABEL_STACK_BLOCKS": "Accessibility label for a block that indicates it is a stack of two or more blocks.", diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index e8d2d8348..4432c2edd 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1880,7 +1880,9 @@ Blockly.Msg.BLOCK_LABEL_HAS_INPUTS = 'has inputs'; /** @type {string} */ /// Part of an accessibility label for a block that indicates that it is /// a statement block, i.e. that it has a next or previous connection. -Blockly.Msg.BLOCK_LABEL_STATEMENT = 'statement'; +/// "command" here is used in the sense of a computer command, or a +/// command block in Scratch. +Blockly.Msg.BLOCK_LABEL_STATEMENT = 'command'; /** @type {string} */ /// Part of an accessibility label for a block that indicates that it is /// a container block, i.e. that it has one or more statement inputs. diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 91227ff4c..2f10259b5 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -295,7 +295,7 @@ suite('ARIA', function () { block.getFocusableElement(), Blockly.utils.aria.State.ROLEDESCRIPTION, ); - assert.equal(roleDescription, 'statement'); + assert.equal(roleDescription, 'command'); }); test('Value blocks have correct role description', function () { diff --git a/packages/blockly/tests/mocha/block_json_test.js b/packages/blockly/tests/mocha/block_json_test.js index 31abd6e34..d645cbd2a 100644 --- a/packages/blockly/tests/mocha/block_json_test.js +++ b/packages/blockly/tests/mocha/block_json_test.js @@ -774,4 +774,36 @@ suite('Block JSON initialization', function () { }); }); }); + suite('blockFromJson', function () { + test('Custom aria role description', function () { + const testBlockDefinition = { + 'type': 'test_block', + 'ariaRoleDescription': 'Custom aria description', + }; + Blockly.common.defineBlocksWithJsonArray([testBlockDefinition]); + const block = this.workspace.newBlock('test_block'); + assert.equal( + block.getAriaRoleDescription(), + 'Custom aria description', + 'Expected getAriaRoleDescription to return the custom description.', + ); + delete Blockly.Blocks['test_block']; + }); + test('Custom aria role description with message reference', function () { + const testBlockDefinition = { + 'type': 'test_block', + 'ariaRoleDescription': '%{BKY_CUSTOM_ROLE_DESCRIPTION}', + }; + Blockly.Msg['CUSTOM_ROLE_DESCRIPTION'] = 'Custom aria description'; + Blockly.common.defineBlocksWithJsonArray([testBlockDefinition]); + const block = this.workspace.newBlock('test_block'); + assert.equal( + block.getAriaRoleDescription(), + 'Custom aria description', + 'Expected getAriaRoleDescription to return the custom description.', + ); + delete Blockly.Blocks['test_block']; + delete Blockly.Msg['CUSTOM_ROLE_DESCRIPTION']; + }); + }); });