diff --git a/packages/blockly/blocks/variables.ts b/packages/blockly/blocks/variables.ts index 4f1f640fa..f05ec1d8a 100644 --- a/packages/blockly/blocks/variables.ts +++ b/packages/blockly/blocks/variables.ts @@ -117,12 +117,12 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = { this.type === 'variables_get' || this.type === 'variables_get_reporter' ) { + const name = this.getField('VAR')!.getText(); const renameOption = { - text: Msg['RENAME_VARIABLE'], + text: Msg['RENAME_VARIABLE'].replace('%1', name), enabled: true, callback: renameOptionCallbackFactory(this), }; - const name = this.getField('VAR')!.getText(); const deleteOption = { text: Msg['DELETE_VARIABLE'].replace('%1', name), enabled: true, diff --git a/packages/blockly/blocks/variables_dynamic.ts b/packages/blockly/blocks/variables_dynamic.ts index 8afd24cf2..7dfc877f5 100644 --- a/packages/blockly/blocks/variables_dynamic.ts +++ b/packages/blockly/blocks/variables_dynamic.ts @@ -117,12 +117,12 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = { this.type === 'variables_get_dynamic' || this.type === 'variables_get_reporter_dynamic' ) { + const name = this.getField('VAR')!.getText(); const renameOption = { - text: Msg['RENAME_VARIABLE'], + text: Msg['RENAME_VARIABLE'].replace('%1', name), enabled: true, callback: renameOptionCallbackFactory(this), }; - const name = this.getField('VAR')!.getText(); const deleteOption = { text: Msg['DELETE_VARIABLE'].replace('%1', name), enabled: true, diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index e8b83c574..1de0d7899 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -373,7 +373,7 @@ export abstract class Field * @param includeTypeInfo Whether to include the field's type information in * the returned label, if available. */ - computeAriaLabel(includeTypeInfo: boolean = false): string { + computeAriaLabel(includeTypeInfo: boolean = true): string { const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null; let ariaValue = this.getAriaValue(); if (ariaValue === null || ariaValue === '') { diff --git a/packages/blockly/core/field_checkbox.ts b/packages/blockly/core/field_checkbox.ts index 55ed42cbf..f09c579be 100644 --- a/packages/blockly/core/field_checkbox.ts +++ b/packages/blockly/core/field_checkbox.ts @@ -16,6 +16,8 @@ import './events/events_block_change.js'; import {Field, FieldConfig, FieldValidator} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import {Msg} from './msg.js'; +import {aria} from './utils.js'; import * as dom from './utils/dom.js'; type BoolString = 'TRUE' | 'FALSE'; @@ -111,6 +113,7 @@ export class FieldCheckbox extends Field { const textElement = this.getTextElement(); dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField'); textElement.style.display = this.value_ ? 'block' : 'none'; + this.recomputeAriaContext(); } override render_() { @@ -170,6 +173,7 @@ export class FieldCheckbox extends Field { if (this.textElement_) { this.textElement_.style.display = this.value_ ? 'block' : 'none'; } + this.recomputeAriaContext(); } /** @@ -213,6 +217,39 @@ export class FieldCheckbox extends Field { return !!value; } + /** + * 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 { + return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_CHECKBOX']; + } + + /** + * 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. + * + * The FieldCheckbox implementation is not used for the actual ARIA label of + * the field, since the checked state is already included in the ARIA checked + * state, but it is used for the ARIA label of its source block. + * + * @returns An ARIA representation of the field's text. + */ + override getAriaValue(): string | null { + // return null; + const checked = this.convertValueToBool(this.value_); + return checked + ? Msg['FIELD_LABEL_CHECKBOX_CHECKED'] + : Msg['FIELD_LABEL_CHECKBOX_UNCHECKED']; + } + /** * Construct a FieldCheckbox from a JSON arg object. * @@ -228,6 +265,31 @@ export class FieldCheckbox extends Field { // 'override' the static fromJson method. return new this(options.checked, undefined, options); } + + /** + * Recomputes the ARIA role and label for this field. + */ + protected 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); + aria.setRole(focusableElement, aria.Role.CHECKBOX); + const checked = this.convertValueToBool(this.value_); + aria.setState(focusableElement, aria.State.CHECKED, checked); + + // Checkbox fields do not use this.computeAriaLabel(), because the + // included 'checked' or 'not checked' state in the ARIA label would + // be redundant with the ARIA checked state. + const label = this.getAriaTypeName(); + + aria.setState(focusableElement, aria.State.LABEL, label); + } } fieldRegistry.register('field_checkbox', FieldCheckbox); diff --git a/packages/blockly/core/field_dropdown.ts b/packages/blockly/core/field_dropdown.ts index 64ecd39a9..136c36f69 100644 --- a/packages/blockly/core/field_dropdown.ts +++ b/packages/blockly/core/field_dropdown.ts @@ -884,7 +884,7 @@ export class FieldDropdown extends Field { * * @returns An ARIA representation of the field's text. */ - override getAriaValue(): string | null { + override getAriaValue(): string { // Note: This fallback is effectively unreachable since computeOptionAriaLabel // always returns a non-empty string for non-separator options. It exists as a // defensive safeguard. @@ -920,7 +920,7 @@ export class FieldDropdown extends Field { /** * Recomputes the ARIA role and label for this field. */ - private recomputeAriaContext(): void { + protected recomputeAriaContext(): void { const focusableElement = this.getFocusableElement(); if (!focusableElement) return; @@ -934,7 +934,7 @@ export class FieldDropdown extends Field { // editing mode that can be activated. aria.setRole(focusableElement, aria.Role.BUTTON); - const label = this.computeAriaLabel(false); + const label = this.computeAriaLabel(true); aria.setState(focusableElement, aria.State.LABEL, label); aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox'); diff --git a/packages/blockly/core/field_image.ts b/packages/blockly/core/field_image.ts index 01133c203..4dce9bb00 100644 --- a/packages/blockly/core/field_image.ts +++ b/packages/blockly/core/field_image.ts @@ -13,6 +13,8 @@ import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import {Msg} from './msg.js'; +import {aria} from './utils.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; @@ -157,6 +159,8 @@ export class FieldImage extends Field { if (this.clickHandler) { this.imageElement.style.cursor = 'pointer'; } + + this.recomputeAriaContext(); } override updateSize_() {} @@ -186,6 +190,7 @@ export class FieldImage extends Field { if (this.imageElement) { this.imageElement.setAttributeNS(dom.XLINK_NS, 'xlink:href', this.value_); } + this.recomputeAriaContext(); } /** @@ -283,6 +288,59 @@ export class FieldImage extends Field { options, ); } + + /** + * 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_IMAGE']; + } + + /** + * 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, or null if no text is + * currently defined or known for the field. + */ + override getAriaValue(): string | null { + return this.altText || null; + } + + /** + * Recomputes the ARIA role and label for this field. + */ + protected recomputeAriaContext(): void { + const focusableElement = this.getClickTarget_(); + if (!focusableElement) return; + + const isInFlyout = this.getSourceBlock()?.isInFlyout; + if (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. The presentation role is used to + // prevent screen readers from reading the content or its descendants. + // Only clickable image fields are navigable. + aria.setRole( + focusableElement, + this.isClickable() ? aria.Role.BUTTON : aria.Role.PRESENTATION, + ); + + const label = this.computeAriaLabel(true); + aria.setState(focusableElement, aria.State.LABEL, label); + } } fieldRegistry.register('field_image', FieldImage); diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index 5f024bdab..892f8abf0 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -839,7 +839,7 @@ export abstract class FieldInput extends Field< /** * Recomputes the ARIA role and label for this field. */ - private recomputeAriaContext(): void { + protected recomputeAriaContext(): void { const focusableElement = this.getClickTarget_(); if (!focusableElement) return; @@ -853,7 +853,7 @@ export abstract class FieldInput extends Field< // editing mode that can be activated. aria.setRole(focusableElement, aria.Role.BUTTON); - let label = this.computeAriaLabel(false); + let label = this.computeAriaLabel(true); if (this.isCurrentlyEditable?.()) { label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label); diff --git a/packages/blockly/core/field_label.ts b/packages/blockly/core/field_label.ts index 236154cc7..16745d3f9 100644 --- a/packages/blockly/core/field_label.ts +++ b/packages/blockly/core/field_label.ts @@ -14,6 +14,7 @@ import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import {aria} from './utils.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -76,6 +77,7 @@ export class FieldLabel extends Field { } if (this.fieldGroup_) { dom.addClass(this.fieldGroup_, 'blocklyLabelField'); + aria.setState(this.fieldGroup_, aria.State.HIDDEN, true); } } diff --git a/packages/blockly/core/field_number.ts b/packages/blockly/core/field_number.ts index 6e7088bce..34b9bfcb0 100644 --- a/packages/blockly/core/field_number.ts +++ b/packages/blockly/core/field_number.ts @@ -19,7 +19,6 @@ import { } from './field_input.js'; import * as fieldRegistry from './field_registry.js'; import {Msg} from './msg.js'; -import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; /** @@ -300,11 +299,9 @@ export class FieldNumber extends FieldInput { // Set the accessibility state if (this.min_ > -Infinity) { htmlInput.min = `${this.min_}`; - aria.setState(htmlInput, aria.State.VALUEMIN, this.min_); } if (this.max_ < Infinity) { htmlInput.max = `${this.max_}`; - aria.setState(htmlInput, aria.State.VALUEMAX, this.max_); } return htmlInput; } diff --git a/packages/blockly/core/field_variable.ts b/packages/blockly/core/field_variable.ts index aa4fdfe31..dfbb218ac 100644 --- a/packages/blockly/core/field_variable.ts +++ b/packages/blockly/core/field_variable.ts @@ -605,7 +605,7 @@ export class FieldVariable extends FieldDropdown { ]; } options.push([ - Msg['RENAME_VARIABLE'], + Msg['RENAME_VARIABLE'].replace('%1', name), internalConstants.RENAME_VARIABLE_ID, ]); if (Msg['DELETE_VARIABLE']) { @@ -617,6 +617,18 @@ export class FieldVariable extends FieldDropdown { return options; } + /** + * 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 { + // Example: 'Variable "i"' + return Msg['FIELD_LABEL_VARIABLE'].replace('%1', super.getAriaValue()); + } } fieldRegistry.register('field_variable', FieldVariable); diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index e71e84af6..caf6c4986 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-24 15:03:55.288228", + "lastupdated": "2026-04-29 08:57:47.670420", "locale": "en", "messagedocumentation" : "qqq" }, @@ -29,7 +29,7 @@ "UNDO": "Undo", "REDO": "Redo", "CHANGE_VALUE_TITLE": "Change value:", - "RENAME_VARIABLE": "Rename variable...", + "RENAME_VARIABLE": "Rename the '%1' variable", "RENAME_VARIABLE_TITLE": "Rename all '%1' variables to:", "NEW_VARIABLE": "Create variable...", "NEW_STRING_VARIABLE": "Create string variable...", @@ -488,6 +488,11 @@ "ARIA_TYPE_FIELD_TEXT_INPUT": "text", "ARIA_TYPE_FIELD_NUMBER": "number", "ARIA_TYPE_FIELD_DROPDOWN": "dropdown", + "ARIA_TYPE_FIELD_IMAGE": "image", + "ARIA_TYPE_FIELD_CHECKBOX": "checkbox", "FIELD_LABEL_EDIT_PREFIX": "Edit %1", - "FIELD_LABEL_OPTION_INDEX": "Option %1" + "FIELD_LABEL_OPTION_INDEX": "Option %1", + "FIELD_LABEL_CHECKBOX_CHECKED": "Checked", + "FIELD_LABEL_CHECKBOX_UNCHECKED": "Not checked", + "FIELD_LABEL_VARIABLE": "Variable '%1'" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index ee2d9be3a..d13ce9e9b 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -495,6 +495,11 @@ "ARIA_TYPE_FIELD_TEXT_INPUT": "ARIA type name for a text input field, used by screen readers to identify the type of field.", "ARIA_TYPE_FIELD_NUMBER": "ARIA type name for a number field, used by screen readers to identify the type of field.", "ARIA_TYPE_FIELD_DROPDOWN": "ARIA type name for a dropdown field, used by screen readers to identify the type of field.", + "ARIA_TYPE_FIELD_IMAGE": "ARIA type name of an image field, used by screen readers to identify the type of field.", + "ARIA_TYPE_FIELD_CHECKBOX": "ARIA type name of an checkbox 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'", - "FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'" + "FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'", + "FIELD_LABEL_CHECKBOX_CHECKED": "Label for a checked checkbox field, used by screen readers to identify the state of a checkbox field.", + "FIELD_LABEL_CHECKBOX_UNCHECKED": "Label for an unchecked checkbox field, used by screen readers to identify the state of a checkbox field.", + "FIELD_LABEL_VARIABLE": "Label for a variable field option, used by screen readers to identify the options in a variable dropdown field. \n\nParameters:\n* %1 - the name of the variable represented by the option \n\nExamples:\n* 'Variable 'item''\n* 'Variable 'x''" } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 1df586d02..fb353964a 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -139,7 +139,7 @@ Blockly.Msg.REDO = 'Redo'; Blockly.Msg.CHANGE_VALUE_TITLE = 'Change value:'; /** @type {string} */ /// dropdown choice - When the user clicks on a variable block, this is one of the dropdown menu choices. It is used to rename the current variable. See [https://github.com/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu https://github.com/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu]. -Blockly.Msg.RENAME_VARIABLE = 'Rename variable...'; +Blockly.Msg.RENAME_VARIABLE = 'Rename the "%1" variable'; /** @type {string} */ /// prompt - Prompts the user to enter the new name for the selected variable. See [https://github.com/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu https://github.com/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu].\n\nParameters:\n* %1 - the name of the variable to be renamed. Blockly.Msg.RENAME_VARIABLE_TITLE = 'Rename all "%1" variables to:'; @@ -1940,6 +1940,12 @@ Blockly.Msg.ARIA_TYPE_FIELD_NUMBER = 'number'; /// ARIA type name for a dropdown field, used by screen readers to identify the type of field. Blockly.Msg.ARIA_TYPE_FIELD_DROPDOWN = 'dropdown'; /** @type {string} */ +/// ARIA type name of an image field, used by screen readers to identify the type of field. +Blockly.Msg.ARIA_TYPE_FIELD_IMAGE = 'image'; +/** @type {string} */ +/// ARIA type name of an checkbox field, used by screen readers to identify the type of field. +Blockly.Msg.ARIA_TYPE_FIELD_CHECKBOX = 'checkbox'; +/** @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" @@ -1948,4 +1954,15 @@ Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1'; /// Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. /// \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 /// \n\nExamples:\n* "Option 1"\n* "Option 2" -Blockly.Msg.FIELD_LABEL_OPTION_INDEX = 'Option %1'; \ No newline at end of file +Blockly.Msg.FIELD_LABEL_OPTION_INDEX = 'Option %1'; +/** @type {string} */ +/// Label for a checked checkbox field, used by screen readers to identify the state of a checkbox field. +Blockly.Msg.FIELD_LABEL_CHECKBOX_CHECKED = 'Checked'; +/** @type {string} */ +/// Label for an unchecked checkbox field, used by screen readers to identify the state of a checkbox field. +Blockly.Msg.FIELD_LABEL_CHECKBOX_UNCHECKED = 'Not checked'; +/** @type {string} */ +/// Label for a variable field option, used by screen readers to identify the options in a variable dropdown field. +/// \n\nParameters:\n* %1 - the name of the variable represented by the option +/// \n\nExamples:\n* 'Variable "item"'\n* 'Variable "x"' +Blockly.Msg.FIELD_LABEL_VARIABLE = 'Variable "%1"'; \ No newline at end of file diff --git a/packages/blockly/tests/mocha/field_checkbox_test.js b/packages/blockly/tests/mocha/field_checkbox_test.js index c639f3581..2c5a249e9 100644 --- a/packages/blockly/tests/mocha/field_checkbox_test.js +++ b/packages/blockly/tests/mocha/field_checkbox_test.js @@ -293,4 +293,50 @@ suite('Checkbox Fields', function () { this.assertValue(false); }); }); + + suite('ARIA', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + this.block = this.workspace.newBlock('test_fields_checkbox'); + this.field = this.block.getField('CHECKBOX'); + this.block.initSvg(); + this.block.render(); + + this.focusableElement = this.field.getClickTarget_(); + }); + test('Block has field type name in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, 'checkbox'); + }); + test('Field ARIA label is type name', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.equal(fieldLabel, 'checkbox'); + }); + test('Field does not include value in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.isFalse(fieldLabel.toLowerCase().includes('checked')); + }); + test('Hidden when in a flyout', function () { + this.block.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('Focusable element has role of checkbox', function () { + const role = this.focusableElement.getAttribute('role'); + assert.equal(role, 'checkbox'); + }); + test('Focusable element has correct default ARIA checked state', function () { + const ariaChecked = this.focusableElement.getAttribute('aria-checked'); + assert.equal(ariaChecked, 'true'); + }); + test('Focusable element updates ARIA checked state on setValue', function () { + this.field.setValue(false); + const ariaChecked = this.focusableElement.getAttribute('aria-checked'); + assert.equal(ariaChecked, 'false'); + }); + }); }); diff --git a/packages/blockly/tests/mocha/field_dropdown_test.js b/packages/blockly/tests/mocha/field_dropdown_test.js index c8d02c480..37af2b277 100644 --- a/packages/blockly/tests/mocha/field_dropdown_test.js +++ b/packages/blockly/tests/mocha/field_dropdown_test.js @@ -345,6 +345,10 @@ suite('Dropdown Fields', function () { const blockLabel = this.block.getAriaLabel(); assert.include(blockLabel, 'dropdown:'); }); + test('Field has field type name in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'dropdown:'); + }); test('Focusable element has role of button', function () { const role = this.focusableElement.getAttribute('role'); assert.equal(role, 'button'); @@ -479,7 +483,7 @@ suite('Dropdown Fields', function () { const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'A'); + assert.include(label, 'A'); }); test('Image ARIA label is prioritized over alt text', function () { this.field.dropdownCreate(); @@ -487,7 +491,7 @@ suite('Dropdown Fields', function () { const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Letter B'); + assert.include(label, 'Letter B'); }); }); suite('Dropdown with HTMLElement options', function () { @@ -549,35 +553,35 @@ suite('Dropdown Fields', function () { const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Explicit A label'); + assert.include(label, 'Explicit A label'); }); test('HTMLElement ariaLabel prioritized over other properties', function () { this.field.setValue('B'); const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Element ARIA'); + assert.include(label, 'Element ARIA'); }); test('HTMLElement title is used when ariaLabel is missing', function () { this.field.setValue('C'); const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Title text'); + assert.include(label, 'Title text'); }); test('HTMLElement innerText is used as final fallback', function () { this.field.setValue('D'); const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Inner text'); + assert.include(label, 'Inner text'); }); test('Empty label falls back to option index', function () { this.field.setValue('E'); const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Option 5'); + assert.include(label, 'Option 5'); }); }); }); diff --git a/packages/blockly/tests/mocha/field_image_test.js b/packages/blockly/tests/mocha/field_image_test.js index f0358703b..c4150d42a 100644 --- a/packages/blockly/tests/mocha/field_image_test.js +++ b/packages/blockly/tests/mocha/field_image_test.js @@ -348,4 +348,55 @@ suite('Image Fields', function () { }); }); }); + suite('ARIA', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + }); + suite('Image without click handler', function () { + setup(function () { + this.block = this.workspace.newBlock('text'); + this.field = this.block.inputList[0].fieldRow[0]; + this.block.initSvg(); + this.block.render(); + this.focusableElement = this.field.getFocusableElement(); + }); + test('Block has field type name in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, 'image:'); + }); + test('Field has field type name in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'image:'); + }); + test('Block has image alt text in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, this.field.altText); + }); + test('Focusable element has role of presentation', function () { + const role = this.focusableElement.getAttribute('role'); + assert.equal(role, 'presentation'); + }); + test('Hidden when in a flyout', function () { + this.block.isInFlyout = true; + // Force recompute of ARIA label. + this.field.setValue(this.field.getValue()); + const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); + assert.equal(ariaHidden, 'true'); + }); + }); + suite('Image with click handler', function () { + test('Focusable element has role of button', function () { + const block = this.workspace.newBlock('test_images_clickhandler'); + const field = block.getField('IMAGE'); + block.initSvg(); + block.render(); + + const focusableElement = field.getFocusableElement(); + const role = focusableElement.getAttribute('role'); + assert.equal(role, 'button'); + }); + }); + }); }); diff --git a/packages/blockly/tests/mocha/field_label_test.js b/packages/blockly/tests/mocha/field_label_test.js index bae600aff..6e5cd1522 100644 --- a/packages/blockly/tests/mocha/field_label_test.js +++ b/packages/blockly/tests/mocha/field_label_test.js @@ -223,4 +223,20 @@ suite('Label Fields', function () { }); }); }); + + suite('ARIA', function () { + test('Is hidden', function () { + const workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + const block = workspace.newBlock('text_print'); + const field = block.inputList[0].fieldRow[0]; + block.initSvg(); + block.render(); + + const focusableElement = field.getFocusableElement(); + const ariaHidden = focusableElement.getAttribute('aria-hidden'); + assert.equal(ariaHidden, 'true'); + }); + }); }); diff --git a/packages/blockly/tests/mocha/field_number_test.js b/packages/blockly/tests/mocha/field_number_test.js index 918bf3917..f040b5597 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -518,6 +518,10 @@ suite('Number Fields', function () { const blockLabel = this.block.getAriaLabel(); assert.include(blockLabel, 'number:'); }); + test('Field has field type name in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'number:'); + }); test('Focusable element has role of button', function () { const role = this.focusableElement.getAttribute('role'); assert.equal(role, 'button'); diff --git a/packages/blockly/tests/mocha/field_textinput_test.js b/packages/blockly/tests/mocha/field_textinput_test.js index ab3fca359..5a1191435 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -609,6 +609,10 @@ suite('Text Input Fields', function () { const blockLabel = this.block.getAriaLabel(); assert.include(blockLabel, 'text:'); }); + test('Field has field type name in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'text:'); + }); test('Focusable element has role of button', function () { const role = this.focusableElement.getAttribute('role'); assert.equal(role, 'button'); diff --git a/packages/blockly/tests/mocha/field_variable_test.js b/packages/blockly/tests/mocha/field_variable_test.js index 270a662cf..c0cdcb669 100644 --- a/packages/blockly/tests/mocha/field_variable_test.js +++ b/packages/blockly/tests/mocha/field_variable_test.js @@ -204,9 +204,17 @@ suite('Variable Fields', function () { for (let i = 0, option; (option = expectedVarOptions[i]); i++) { assert.deepEqual(dropdownOptions[i], option); } - assert.include(dropdownOptions[dropdownOptions.length - 2][0], 'Rename'); + const varName = fieldVariable.getText(); - assert.include(dropdownOptions[dropdownOptions.length - 1][0], 'Delete'); + const renameLabel = dropdownOptions[dropdownOptions.length - 2][0]; + const deleteLabel = dropdownOptions[dropdownOptions.length - 1][0]; + + // Expect the rename and delete options to include the variable name. + assert.include(renameLabel, 'Rename'); + assert.include(renameLabel, varName); + + assert.include(deleteLabel, 'Delete'); + assert.include(deleteLabel, varName); }; test('Contains variables created before field', function () { this.workspace.getVariableMap().createVariable('name1', '', 'id1'); @@ -641,4 +649,75 @@ suite('Variable Fields', function () { assert.equal(variable.getId(), 'id2'); }); }); + suite('ARIA', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + this.block = this.workspace.newBlock('variables_set'); + this.field = this.block.getField('VAR'); + this.block.initSvg(); + this.block.render(); + + this.focusableElement = this.field.getFocusableElement(); + }); + test('Block has dropdown field type name and "Variable" qualifier in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, 'dropdown:'); + }); + test('Field has dropdown field type name and "Variable" qualifier in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'dropdown:'); + assert.include(fieldLabel, 'Variable'); + }); + 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.block.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('Does not have aria-expanded when dropdown is closed', function () { + const ariaExpanded = this.focusableElement.getAttribute('aria-expanded'); + assert.equal(ariaExpanded, 'false'); + }); + test('Has aria-expanded when dropdown is open', function () { + this.field.showEditor_(); + const ariaExpanded = this.focusableElement.getAttribute('aria-expanded'); + assert.equal(ariaExpanded, 'true'); + this.workspace.hideChaff(); + }); + test('Has aria-haspopup of listbox', function () { + const ariaHasPopup = this.focusableElement.getAttribute('aria-haspopup'); + assert.equal(ariaHasPopup, 'listbox'); + }); + test('Has aria-controls that matches the ID of the dropdown menu', function () { + this.field.showEditor_(); + const ariaControls = this.focusableElement.getAttribute('aria-controls'); + const menuId = this.field.menu_.id; + assert.equal(ariaControls, menuId); + this.workspace.hideChaff(); + }); + test('Has placeholder ARIA label by default', function () { + const label = this.focusableElement.getAttribute('aria-label'); + assert.include(label, 'item'); + }); + test('New selected option updates ARIA label', function () { + const initialLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(initialLabel, 'item'); + + const newVariable = this.workspace + .getVariableMap() + .createVariable('newVar'); + this.field.getOptions(false); // Invalidate cached options. + this.field.setValue(newVariable.getId()); + + const updatedLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(updatedLabel, 'newVar'); + }); + }); });