diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index b254e6704a6..7e13d6c5d29 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -254,7 +254,7 @@ export abstract class Field this.setTooltip(parsing.replaceMessageReferences(config.tooltip)); } if (config.ariaTypeName) { - this.ariaTypeName = config.ariaTypeName; + this.setAriaTypeName(config.ariaTypeName); } } @@ -322,12 +322,15 @@ export abstract class Field * Sets the ARIA-friendly label representation of this field's type. * * Implementations are responsible for, and encouraged to, set a localized - * version of the ARIA representation of the field's type. + * version of the ARIA representation of the field's type. To that end, the + * provided value may contain message references of the form `%{BKY_...}` + * (e.g. `%{BKY_MY_FIELD_ARIA_TYPE}`), which are replaced with the + * corresponding `Blockly.Msg` value. * * @param ariaTypeName An ARIA representation of the field's type. */ setAriaTypeName(ariaTypeName: string) { - this.ariaTypeName = ariaTypeName; + this.ariaTypeName = parsing.replaceMessageReferences(ariaTypeName); } /** diff --git a/packages/blockly/core/interfaces/i_json_block_definition.ts b/packages/blockly/core/interfaces/i_json_block_definition.ts index 0c2cef23582..135d3082e27 100644 --- a/packages/blockly/core/interfaces/i_json_block_definition.ts +++ b/packages/blockly/core/interfaces/i_json_block_definition.ts @@ -71,28 +71,39 @@ interface UnknownArg { [key: string]: unknown; } +/** + * A custom ARIA label for the input, used in place of the label derived from + * the input's preceding fields. May contain message references of the form + * `%{BKY_...}`, which are replaced with the corresponding `Blockly.Msg` value. + */ +type AriaLabelText = string; + /** Input args */ interface InputValueArg { type: 'input_value'; name?: string; check?: string | string[]; align?: Align; + ariaLabelText?: AriaLabelText; } interface InputStatementArg { type: 'input_statement'; name?: string; check?: string | string[]; + ariaLabelText?: AriaLabelText; } interface InputDummyArg { type: 'input_dummy'; name?: string; + ariaLabelText?: AriaLabelText; } interface InputEndRowArg { type: 'input_end_row'; name?: string; + ariaLabelText?: AriaLabelText; } /** Field args */ diff --git a/packages/blockly/tests/mocha/block_json_test.js b/packages/blockly/tests/mocha/block_json_test.js index d645cbd2a75..02ed05a012d 100644 --- a/packages/blockly/tests/mocha/block_json_test.js +++ b/packages/blockly/tests/mocha/block_json_test.js @@ -805,5 +805,95 @@ suite('Block JSON initialization', function () { delete Blockly.Blocks['test_block']; delete Blockly.Msg['CUSTOM_ROLE_DESCRIPTION']; }); + test('Custom input aria label', function () { + const testBlockDefinition = { + 'type': 'test_block', + 'message0': 'test %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'INPUT', + 'ariaLabelText': 'Custom input label', + }, + ], + }; + Blockly.common.defineBlocksWithJsonArray([testBlockDefinition]); + const block = this.workspace.newBlock('test_block'); + assert.equal( + block.getInput('INPUT').getAriaLabelText(), + 'Custom input label', + 'Expected getAriaLabelText to return the custom label.', + ); + delete Blockly.Blocks['test_block']; + }); + test('Custom input aria label with message reference', function () { + const testBlockDefinition = { + 'type': 'test_block', + 'message0': 'test %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'INPUT', + 'ariaLabelText': '%{BKY_CUSTOM_INPUT_LABEL}', + }, + ], + }; + Blockly.Msg['CUSTOM_INPUT_LABEL'] = 'Custom input label'; + Blockly.common.defineBlocksWithJsonArray([testBlockDefinition]); + const block = this.workspace.newBlock('test_block'); + assert.equal( + block.getInput('INPUT').getAriaLabelText(), + 'Custom input label', + 'Expected getAriaLabelText to return the custom label.', + ); + delete Blockly.Blocks['test_block']; + delete Blockly.Msg['CUSTOM_INPUT_LABEL']; + }); + test('Custom field aria type name', function () { + const testBlockDefinition = { + 'type': 'test_block', + 'message0': 'test %1', + 'args0': [ + { + 'type': 'field_number', + 'name': 'FIELD', + 'value': 100, + 'ariaTypeName': 'speed', + }, + ], + }; + Blockly.common.defineBlocksWithJsonArray([testBlockDefinition]); + const block = this.workspace.newBlock('test_block'); + assert.equal( + block.getField('FIELD').getAriaTypeName(), + 'speed', + 'Expected getAriaTypeName to return the custom type name.', + ); + delete Blockly.Blocks['test_block']; + }); + test('Custom field aria type name with message reference', function () { + const testBlockDefinition = { + 'type': 'test_block', + 'message0': 'test %1', + 'args0': [ + { + 'type': 'field_number', + 'name': 'FIELD', + 'value': 100, + 'ariaTypeName': '%{BKY_CUSTOM_FIELD_TYPE}', + }, + ], + }; + Blockly.Msg['CUSTOM_FIELD_TYPE'] = 'speed'; + Blockly.common.defineBlocksWithJsonArray([testBlockDefinition]); + const block = this.workspace.newBlock('test_block'); + assert.equal( + block.getField('FIELD').getAriaTypeName(), + 'speed', + 'Expected getAriaTypeName to return the custom type name.', + ); + delete Blockly.Blocks['test_block']; + delete Blockly.Msg['CUSTOM_FIELD_TYPE']; + }); }); }); diff --git a/packages/blockly/tests/mocha/field_test.js b/packages/blockly/tests/mocha/field_test.js index e01369a2462..adfc6b43c97 100644 --- a/packages/blockly/tests/mocha/field_test.js +++ b/packages/blockly/tests/mocha/field_test.js @@ -851,6 +851,26 @@ suite('Abstract Fields', function () { const field = CustomField.fromJson({ariaTypeName: 'text input'}); assert.equal(field.getAriaTypeName(), 'text input'); }); + + suite('W/ Msg References', function () { + setup(function () { + addMessageToCleanup(this.sharedCleanup, 'ARIA_TYPE'); + Blockly.Msg['ARIA_TYPE'] = 'speed'; + }); + + test('setAriaTypeName resolves message references', function () { + const field = new TestField(); + field.setAriaTypeName('%{BKY_ARIA_TYPE}'); + assert.equal(field.getAriaTypeName(), 'speed'); + }); + + test('Configured ariaTypeName resolves message references', function () { + const field = new TestField('value', { + ariaTypeName: '%{BKY_ARIA_TYPE}', + }); + assert.equal(field.getAriaTypeName(), 'speed'); + }); + }); }); suite('getAriaValue', function () { diff --git a/packages/docs/docs/guides/create-custom-blocks/define/structure-js.mdx b/packages/docs/docs/guides/create-custom-blocks/define/structure-js.mdx index 537f2c199c9..b4c40316d74 100644 --- a/packages/docs/docs/guides/create-custom-blocks/define/structure-js.mdx +++ b/packages/docs/docs/guides/create-custom-blocks/define/structure-js.mdx @@ -72,6 +72,86 @@ All of the `appendInput` methods (both generic and non-generic) return the input object so that they can be further configured using method chaining. There are three built-in methods used for configuring inputs. +### Input labels + +Inputs on blocks have default accessibility labels that are derived from the +surrounding text on the block. For example, the "in list" value input on the +`for each` block above is labeled `in list`, which comes from the label field +that precedes it. A screen reader uses this label to describe the input's empty +connection and to give context to any block plugged into it. + +For some custom blocks, this derived label does not provide enough information. +Consider a block that builds a coordinate point: + +A block with the label "point (", a value input, the label ",", another value input, and the label ")". + +Both value inputs are preceded only by punctuation, so the highlighted input's +derived label would read `,`, which tells assistive technology users nothing +about what belongs there. To provide more information, you can set custom labels. +In this case, "x coordinate" and "y coordinate" would be appropriate. + +You can set the label for an input by calling `setAriaLabelProvider` on the +input in the block definition. The provider can be either a string or a function +that returns a string, to support dynamic labels. + + + + ```js + init: function() { + // ... + this.appendValueInput('X') + .setAriaLabelProvider('x coordinate') + .appendField('point ('); + this.appendValueInput('Y') + .setAriaLabelProvider('y coordinate') + .appendField(','); + this.appendDummyInput() + .appendField(')'); + } + ``` + + + +Passing a function instead of a string lets you build the label dynamically, for +example from the block's current field values. The function receives the +`Input` and returns a string (or `null` to fall back to the default label). + + + + ```js + init: function() { + // ... + this.appendValueInput('DURATION') + .setAriaLabelProvider((input) => { + const units = input.getSourceBlock().getFieldValue('UNITS'); + return `duration in ${units}`; + }) + .appendField('wait'); + } + ``` + + + +To provide a localized label, pass a string containing a +[`Blockly.Msg`](/guides/configure/translations) reference of the form +`%{BKY_...}`. Blockly replaces the reference with the corresponding message at +render time. + + + + ```js + init: function() { + // ... + this.appendValueInput('X') + .setAriaLabelProvider('%{BKY_POINT_X_ARIA_LABEL}') + .appendField('point ('); + } + ``` + + + +You can set the same labels [in JSON](/guides/create-custom-blocks/define/structure-json#input-labels). + ## Append fields Once an input has been created and appended to a block with `appendInput`, one @@ -120,6 +200,76 @@ The `appendField('hello')` call is actually a shortcut for using an explicit The only time one would wish to use the constructor is when specifying a class name so that the label may be styled using a CSS rule. +### Field labels + +Similar to inputs, every field has an accessibility label. The default label is +determined by the type and value of the field, in the form +`: `. For example, a `FieldNumber` with the value 123 is labeled +`number: 123`. + +For some blocks, you might want to use a more specific description for the type +of field than simply "number" or "dropdown". A block that sets the speed of a +motor might want to label its number field as "speed", for example, so that a +screen reader announces `speed: 100` instead of `number: 100`. + +There are two ways to set the type name for a particular field instance on a +block. + +The simplest is to pass an `ariaTypeName` in the field's constructor +configuration object. This object is the field's last constructor argument (its +position depends on the field type), and is where you configure other field +options too. + + + + ```js + init: function() { + // ... + this.appendDummyInput() + .appendField('set motor speed to') + .appendField( + new Blockly.FieldNumber( + 100, // value + 0, // min + 255, // max + undefined, // precision + undefined, // validator + {ariaTypeName: 'speed'}, // config + ), + 'SPEED'); + } + ``` + + + +Alternatively, call `setAriaTypeName` on the field: + + + + ```js + init: function() { + // ... + const speed = new Blockly.FieldNumber(100, 0, 255); + speed.setAriaTypeName('speed'); + this.appendDummyInput() + .appendField('set motor speed to') + .appendField(speed, 'SPEED'); + } + ``` + + + +Either way, the type name may contain a +[`Blockly.Msg`](/guides/configure/translations) reference of the form +`%{BKY_...}` for localization, the same as input labels and role descriptions. + +To set the label for all instances of a particular type of field, override +`getAriaTypeName` in a [custom +field](/guides/create-custom-blocks/fields/customizing-fields/creating#aria-value) +instead of setting it per instance. + +You can set the type name [in JSON](/guides/create-custom-blocks/define/structure-json#field-labels) as well. + ## Connection checks @@ -163,3 +313,70 @@ and right are reversed. Thus `Blockly.inputs.Align.RIGHT` would align fields to the left. [custom-inputs]: /guides/create-custom-blocks/inputs/creating-custom-inputs + +## Accessibility role descriptions + +All blocks have an [`aria-roledescription`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-roledescription) +so that screen readers announce the block with a useful role rather than the +default "graphics element". + +The default roles Blockly uses are based on the connections available on a +block. They are: + +- `container` (has at least one statement input) +- `value` (has an output connection) +- `statement` (all other blocks) + +These role descriptions are localized. Note that we do not include the word +"block" in the role description, as feedback from users was that including the +word "block" was too repetitive and noisy in the screen reader output. + +### Custom role descriptions + +In some situations, you may want to set a custom role description on your custom +blocks. We recommend that in most cases you use the default role descriptions +rather than setting a custom role description for every block. Generally, the +text on the block, forming the aria label for it, should tell users what the +block does. The role description gives users a hint about the shape of the block +and what kind of connections it can form. + +If you have blocks that are a different shape from other blocks, that may be a +good use case for setting a custom role description. For example, boolean blocks +in the Zelos renderer have a hexagonal shape and can only be put into certain +input connections, so they are different from other "value" blocks. You can set +a custom role description by calling `setAriaRoleDescriptionProvider` in the +block definition. As with input labels, you can pass either a string or a +function that returns a string. + + + + ```js + init: function() { + // ... + this.setAriaRoleDescriptionProvider('boolean'); + } + ``` + + + +To localize the role description, pass a string containing a `Blockly.Msg` +reference of the form `%{BKY_...}`; Blockly replaces it at render time. + + + + ```js + init: function() { + // ... + this.setAriaRoleDescriptionProvider('%{BKY_BOOLEAN_ROLE_DESCRIPTION}'); + } + ``` + + + +You can set a custom role description [in JSON](/guides/create-custom-blocks/define/structure-json#role-descriptions) as well. + +:::note +By default, Blockly does not set boolean blocks in Zelos to a "boolean" role +description, as not all users will be familiar with this term. However, you can +do this in your own application if the term makes sense for your users. +::: diff --git a/packages/docs/docs/guides/create-custom-blocks/define/structure-json.mdx b/packages/docs/docs/guides/create-custom-blocks/define/structure-json.mdx index 471a9d2560f..530b8b3a9bf 100644 --- a/packages/docs/docs/guides/create-custom-blocks/define/structure-json.mdx +++ b/packages/docs/docs/guides/create-custom-blocks/define/structure-json.mdx @@ -394,5 +394,110 @@ An `alt` object may have its own `alt` object, thus allowing for chaining. Ultimately, if Blockly cannot create an object in the `args0` array (after attempting any `alt` objects) then that object is simply skipped. +## Accessibility labels and roles + +Blockly generates accessibility labels and roles for blocks, inputs, and fields +so that screen readers can describe them. You can customize these from JSON. For +a fuller explanation of what each one controls, see the corresponding sections +of the [JavaScript structure +guide](/guides/create-custom-blocks/define/structure-js#input-labels). + +Unlike the JavaScript API, the JSON properties accept only strings (not +functions). All of them support +[`Blockly.Msg`](/guides/configure/translations) references of the form +`%{BKY_...}`, which are replaced at render time. + +### Input labels + +An input's accessibility label is derived from the label fields that precede it. +When that derived label isn't descriptive enough, add an `ariaLabelText` +property to the input's argument object to override it. For example, the two +value inputs in this coordinate block are preceded only by punctuation, so each +one gets a custom label: + + + + ```js + { + "message0": "point ( %1 , %2 )", + "args0": [ + { + "type": "input_value", + "name": "X", + "ariaLabelText": "x coordinate" + }, + { + "type": "input_value", + "name": "Y", + "ariaLabelText": "y coordinate" + } + ] + } + ``` + + + +A block with the label "point (", a value input, the label ",", another value input, and the label ")". + +### Field labels + +Every field has a default accessibility label of the form `: ` +(for example, `number: 100`). To use a more specific type name for a particular +field, add an `ariaTypeName` property to the field's argument object. The motor +block below is announced as `speed: 100` instead of `number: 100`: + + + + ```js + { + "message0": "set motor speed to %1", + "args0": [ + { + "type": "field_number", + "name": "SPEED", + "value": 100, + "ariaTypeName": "speed" + } + ] + } + ``` + + + +To set the type name for all instances of a field type, override +`getAriaTypeName` in a [custom +field](/guides/create-custom-blocks/fields/customizing-fields/creating#aria-value) +instead. + +### Role descriptions + +Every block has an +[`aria-roledescription`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-roledescription) +that describes its shape. By default it is `container`, `value`, or `statement`, +based on the block's connections. To override it, add a top-level +`ariaRoleDescription` property to the block definition. For example, a +hexagonal boolean block in the Zelos renderer might use: + + + + ```js + { + "message0": "%1 and %2", + "args0": [ + {"type": "input_value", "name": "A", "check": "Boolean"}, + {"type": "input_value", "name": "B", "check": "Boolean"} + ], + "output": "Boolean", + "ariaRoleDescription": "boolean" + } + ``` + + + +In most cases you should rely on the default role descriptions rather than +setting a custom one for every block. See [Custom role +descriptions](/guides/create-custom-blocks/define/structure-js#custom-role-descriptions) +for guidance on when a custom role description is worthwhile. + [custom-inputs]: /guides/create-custom-blocks/inputs/creating-custom-inputs [custom-fields]: /guides/create-custom-blocks/fields/customizing-fields/creating diff --git a/packages/docs/static/images/structure-js/aria-input-label.png b/packages/docs/static/images/structure-js/aria-input-label.png new file mode 100644 index 00000000000..c52fb40986d Binary files /dev/null and b/packages/docs/static/images/structure-js/aria-input-label.png differ