From 24aae7d5323841d6bbed31e490d602197792e60d Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:08:23 -0400 Subject: [PATCH 1/3] feat: FieldDropdown aria --- packages/blockly/core/block_aria_composer.ts | 2 +- packages/blockly/core/field_dropdown.ts | 185 ++++++++++++- packages/blockly/core/field_number.ts | 14 + packages/blockly/core/field_textinput.ts | 14 + packages/blockly/core/menu.ts | 12 + packages/blockly/core/menuitem.ts | 25 +- packages/blockly/msg/json/en.json | 10 +- packages/blockly/msg/json/qqq.json | 6 +- packages/blockly/msg/messages.js | 18 +- .../tests/mocha/field_dropdown_test.js | 257 ++++++++++++++++++ .../blockly/tests/mocha/field_number_test.js | 4 + .../tests/mocha/field_textinput_test.js | 4 + 12 files changed, 530 insertions(+), 21 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index e578154d354..03e53458b19 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -5,7 +5,6 @@ */ import type {BlockSvg} from './block_svg.js'; -import {RenderedConnection} from './blockly.js'; import {ConnectionType} from './connection_type.js'; import type {Input} from './inputs/input.js'; import {inputTypes} from './inputs/input_types.js'; @@ -14,6 +13,7 @@ import { isSelectableToolboxItem, } from './interfaces/i_selectable_toolbox_item.js'; import {Msg} from './msg.js'; +import {RenderedConnection} from './rendered_connection.js'; import {Role, setRole, setState, State, Verbosity} from './utils/aria.js'; /** diff --git a/packages/blockly/core/field_dropdown.ts b/packages/blockly/core/field_dropdown.ts index 3be5c94c3e3..3176f4dd649 100644 --- a/packages/blockly/core/field_dropdown.ts +++ b/packages/blockly/core/field_dropdown.ts @@ -25,6 +25,7 @@ import * as fieldRegistry from './field_registry.js'; import {Menu} from './menu.js'; import {MenuSeparator} from './menu_separator.js'; import {MenuItem} from './menuitem.js'; +import {Msg} from './msg.js'; import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; @@ -98,6 +99,8 @@ export class FieldDropdown extends Field { /** The total vertical padding above and below an image. */ protected static IMAGE_Y_PADDING = FieldDropdown.IMAGE_Y_OFFSET * 2; + isInitialized: boolean = false; + /** * @param menuGenerator A non-empty array of options for a dropdown list, or a * function which generates these options. Also accepts Field.SKIP_SETUP @@ -197,6 +200,8 @@ export class FieldDropdown extends Field { dom.addClass(this.fieldGroup_, 'blocklyField'); dom.addClass(this.fieldGroup_, 'blocklyDropdownField'); } + this.recomputeAriaContext(); + this.isInitialized = true; } /** @@ -295,6 +300,7 @@ export class FieldDropdown extends Field { } this.applyColour(); + aria.setState(this.getFocusableElement(), aria.State.EXPANDED, true); } /** Create the dropdown editor. */ @@ -306,6 +312,11 @@ export class FieldDropdown extends Field { const menu = new Menu(); menu.setRole(aria.Role.LISTBOX); this.menu_ = menu; + aria.setState( + this.getFocusableElement(), + aria.State.CONTROLS, + this.menu_.getId(), + ); const options = this.getOptions(false); this.selectedMenuItem = null; @@ -317,6 +328,7 @@ export class FieldDropdown extends Field { } const [label, value] = option; + const ariaLabel = this.computeOptionAriaLabel(option, i); const content = (() => { if (isImageProperties(label)) { // Convert ImageProperties to an HTMLImageElement. @@ -327,7 +339,7 @@ export class FieldDropdown extends Field { } return label; })(); - const menuItem = new MenuItem(content, value); + const menuItem = new MenuItem(content, value, ariaLabel); menuItem.setRole(aria.Role.OPTION); menuItem.setRightToLeft(block.RTL); menuItem.setCheckable(true); @@ -350,6 +362,7 @@ export class FieldDropdown extends Field { this.menu_ = null; this.selectedMenuItem = null; this.applyColour(); + aria.setState(this.getFocusableElement(), aria.State.EXPANDED, false); } /** @@ -472,6 +485,9 @@ export class FieldDropdown extends Field { this.selectedOption = option; } } + if (this.isInitialized) { + this.recomputeAriaContext(); + } } /** @@ -653,7 +669,7 @@ export class FieldDropdown extends Field { typeof HTMLElement !== 'undefined' && option instanceof HTMLElement ) { - return option.title ?? option.ariaLabel ?? option.innerText; + return option.title || (option.ariaLabel ?? option.innerText); } else if (typeof option === 'string') { return option; } @@ -705,9 +721,16 @@ export class FieldDropdown extends Field { return option; } - const [label, value] = option; + const [label, value, ariaLabel] = option; if (typeof label === 'string') { - return [parsing.replaceMessageReferences(label), value]; + const trimmedLabelOption: MenuOption = [ + parsing.replaceMessageReferences(label), + value, + ]; + if (ariaLabel) { + trimmedLabelOption.push(ariaLabel); + } + return trimmedLabelOption; } hasNonTextContent = true; @@ -716,14 +739,18 @@ export class FieldDropdown extends Field { const imageLabel = isImageProperties(label) ? {...label, alt: parsing.replaceMessageReferences(label.alt)} : label; - return [imageLabel, value]; + const imageOptions: MenuOption = [imageLabel, value]; + if (ariaLabel) { + imageOptions.push(ariaLabel); + } + return imageOptions; }); if (hasNonTextContent || options.length < 2) { return {options: trimmedOptions}; } - const stringOptions = trimmedOptions as [string, string][]; + const stringOptions = trimmedOptions as [string, string, string][]; const stringLabels = stringOptions.map(([label]) => label); const shortest = utilsString.shortestStringLength(stringLabels); @@ -762,14 +789,20 @@ export class FieldDropdown extends Field { * @returns A new array with all of the option text trimmed. */ private applyTrim( - options: [string, string][], + options: [string, string, string?][], prefixLength: number, suffixLength: number, ): MenuOption[] { - return options.map(([text, value]) => [ - text.substring(prefixLength, text.length - suffixLength), - value, - ]); + return options.map(([text, value, ariaLabel]) => { + const trimmedText = text.substring( + prefixLength, + text.length - suffixLength, + ); + + return ariaLabel !== undefined + ? [trimmedText, value, ariaLabel] + : [trimmedText, value]; + }); } /** @@ -813,12 +846,139 @@ export class FieldDropdown extends Field { `Invalid option[${i}]: Each FieldDropdown option must have a string label, image description, or HTML element. Found ${option[0]} in: ${option}`, ); + } else if (option[2] && typeof option[2] !== 'string') { + foundError = true; + console.error( + `Invalid option[${i}]: Each FieldDropdown option ARIA label must be a string. + Found ${option[2]} in: ${option}`, + ); } } if (foundError) { throw TypeError('Found invalid FieldDropdown 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_DROPDOWN']; + } + + /** + * 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 { + // Note: This fallback is effectively unreachable since computeOptionAriaLabel + // always returns a non-empty string for non-separator options. It exists as a + // defensive safeguard. + return ( + this.getSelectedAriaLabel() || this.getText() || Msg['FIELD_LABEL_EMPTY'] + ); + } + + /** + * Returns the ARIA label for the currently selected dropdown option. + * + * @returns The computed ARIA label for the selected option, or `null` if no + * option is selected. + */ + private getSelectedAriaLabel(): string | null { + if (!this.selectedOption) { + return null; + } + + const option = this.selectedOption; + const ariaLabel = this.computeOptionAriaLabel( + option, + this.getOptions(false).indexOf(option), + ); + + if (typeof ariaLabel === 'string') { + return ariaLabel; + } + + return null; + } + + /** + * Recomputes the ARIA role and label for this field. + */ + private recomputeAriaContext(): void { + const focusableElement = this.getFocusableElement(); + 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); + + const label = this.computeAriaLabel(false); + + aria.setState(focusableElement, aria.State.LABEL, label); + aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox'); + aria.setState(focusableElement, aria.State.EXPANDED, !!this.menu_); + } + + /** + * Computes an ARIA-friendly label for a dropdown option. + * + * The label is derived using a prioritized set of sources. + * + * Returned values are guaranteed to be non-empty strings for all non-separator + * options. Whitespace-only values are ignored when determining a usable label. + * + * @param option The dropdown option for which to compute the ARIA label. + * @param index The index of the option within the dropdown (0-based). + * @returns A string suitable for use as an ARIA label. Returns an empty string + * only if the option is a separator. + */ + private computeOptionAriaLabel(option: MenuOption, index: number): string { + if (option === FieldDropdown.SEPARATOR) return ''; + + const [label, , explicitAriaLabel] = option; + + if (typeof explicitAriaLabel === 'string' && explicitAriaLabel.trim()) { + return explicitAriaLabel; + } + + let text: string | null = null; + + if (isImageProperties(label)) { + text = label.ariaLabel ?? label.alt; + } else if ( + typeof HTMLElement !== 'undefined' && + label instanceof HTMLElement + ) { + // This chain is similar to getText_, but prioritizes ariaLabel over title. + text = label.ariaLabel ?? (label.title || label.innerText); + } else if (typeof label === 'string') { + text = label; + } + + if (text && text.trim()) { + return text; + } + + // If we can't find any text to use for the ARIA label, use the option index. + return Msg['FIELD_LABEL_OPTION_INDEX'].replace('%1', String(index + 1)); + } } /** @@ -850,6 +1010,7 @@ export interface ImageProperties { alt: string; width: number; height: number; + ariaLabel?: string; } /** @@ -860,7 +1021,7 @@ export interface ImageProperties { * the language-neutral value. */ export type MenuOption = - | [string | ImageProperties | HTMLElement, string] + | [string | ImageProperties | HTMLElement, string, string?] | 'separator'; /** diff --git a/packages/blockly/core/field_number.ts b/packages/blockly/core/field_number.ts index 7e36591753e..6e7088bced7 100644 --- a/packages/blockly/core/field_number.ts +++ b/packages/blockly/core/field_number.ts @@ -18,6 +18,7 @@ import { FieldInputValidator, } 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'; @@ -341,6 +342,19 @@ export class FieldNumber extends FieldInput { 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_NUMBER']; + } } fieldRegistry.register('field_number', FieldNumber); diff --git a/packages/blockly/core/field_textinput.ts b/packages/blockly/core/field_textinput.ts index 2b896ad47be..bd6f8cff331 100644 --- a/packages/blockly/core/field_textinput.ts +++ b/packages/blockly/core/field_textinput.ts @@ -21,6 +21,7 @@ import { FieldInputValidator, } from './field_input.js'; import * as fieldRegistry from './field_registry.js'; +import {Msg} from './msg.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -89,6 +90,19 @@ export class FieldTextInput extends FieldInput { // override the static fromJson method. return new this(text, undefined, 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_TEXT_INPUT']; + } } fieldRegistry.register('field_input', FieldTextInput); diff --git a/packages/blockly/core/menu.ts b/packages/blockly/core/menu.ts index a064489bae8..8fb2849778a 100644 --- a/packages/blockly/core/menu.ts +++ b/packages/blockly/core/menu.ts @@ -16,6 +16,7 @@ import type {MenuSeparator} from './menu_separator.js'; import {MenuItem} from './menuitem.js'; import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; +import * as idGenerator from './utils/idgenerator.js'; import type {Size} from './utils/size.js'; import * as style from './utils/style.js'; @@ -62,6 +63,9 @@ export class Menu { /** ARIA name for this menu. */ private roleName: aria.Role | null = null; + /** The menu's ID. */ + private id: string | null = null; + /** Constructs a new Menu instance. */ constructor() {} @@ -86,6 +90,7 @@ export class Menu { const element = document.createElement('div'); element.className = 'blocklyMenu'; element.tabIndex = 0; + element.id = this.getId(); if (this.roleName) { aria.setRole(element, this.roleName); } @@ -483,4 +488,11 @@ export class Menu { private getMenuItems(): MenuItem[] { return this.menuItems.filter((item) => item instanceof MenuItem); } + + getId(): string { + if (!this.id) { + this.id = idGenerator.getNextUniqueId(); + } + return this.id; + } } diff --git a/packages/blockly/core/menuitem.ts b/packages/blockly/core/menuitem.ts index 454e35744ef..f3eb48b0d3c 100644 --- a/packages/blockly/core/menuitem.ts +++ b/packages/blockly/core/menuitem.ts @@ -43,6 +43,9 @@ export class MenuItem { private actionHandler: ((obj: this, menuSelectEvent: Event) => void) | null = null; + /** The unique ID for this menu item. */ + private id: string | null = null; + /** * @param content Text caption to display as the content of the item, or a * HTML element to display. @@ -51,6 +54,7 @@ export class MenuItem { constructor( private readonly content: string | HTMLElement, private readonly opt_value?: string, + private readonly ariaLabel?: string, ) {} /** @@ -60,7 +64,7 @@ export class MenuItem { */ createDom(): Element { const element = document.createElement('div'); - element.id = idGenerator.getNextUniqueId(); + element.id = this.getId(); this.element = element; // Set class and style @@ -72,6 +76,10 @@ export class MenuItem { (this.rightToLeft ? 'blocklyMenuItemRtl ' : ''); const content = document.createElement('div'); + aria.setState(element, aria.State.LABEL, this.getAriaLabel()); + // The presentation role is used to prevent screen readers from also reading the + // content or its descendants. + aria.setRole(content, aria.Role.PRESENTATION); content.className = 'blocklyMenuItemContent'; let contentDom: Node = this.content as HTMLElement; @@ -100,6 +108,15 @@ export class MenuItem { return element; } + /** + * Gets the ARIA label for this menu item. + */ + getAriaLabel(): string { + // This fallback should only be hit by Context Menu items as all + // FieldDropdown options should have an ARIA label. + return this.ariaLabel || String(this.content); + } + /** Dispose of this menu item. */ dispose() { this.element = null; @@ -122,7 +139,10 @@ export class MenuItem { * @internal */ getId(): string { - return this.element!.id; + if (!this.id) { + this.id = idGenerator.getNextUniqueId(); + } + return this.id; } /** @@ -276,6 +296,7 @@ export class MenuItem { } const checkbox = document.createElement('div'); + aria.setState(checkbox, aria.State.HIDDEN, true); checkbox.className = 'blocklyMenuItemCheckbox '; this.getElement() ?.querySelector('.blocklyMenuItemContent') diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 12fbfe682ab..2e96c6c2fe4 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 17:30:08.288719", + "lastupdated": "2026-04-24 15:03:55.288228", "locale": "en", "messagedocumentation" : "qqq" }, @@ -483,6 +483,10 @@ "ANNOUNCE_MOVE_TO": "moving %1 %2 to %3 %4", "ANNOUNCE_MOVE_CANCELED": "Canceled movement", "FIELD_LABEL_EMPTY": "empty", - "ARIA_TYPE_FIELD_INPUT": "input field", - "FIELD_LABEL_EDIT_PREFIX": "Edit %1" + "ARIA_TYPE_FIELD_INPUT": "input", + "ARIA_TYPE_FIELD_TEXT_INPUT": "text", + "ARIA_TYPE_FIELD_NUMBER": "number", + "ARIA_TYPE_FIELD_DROPDOWN": "dropdown", + "FIELD_LABEL_EDIT_PREFIX": "Edit %1", + "FIELD_LABEL_OPTION_INDEX": "Option %1" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index d2fcf86cb33..54bc08dc10d 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -491,5 +491,9 @@ "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.", "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'" + "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.", + "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'" } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index d5cd502d7cb..f1b604e7c7a 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1926,9 +1926,23 @@ Blockly.Msg.ANNOUNCE_MOVE_CANCELED = 'Canceled movement'; 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'; +Blockly.Msg.ARIA_TYPE_FIELD_INPUT = 'input'; +/** @type {string} */ +/// ARIA type name for a text input field, used by screen readers to identify the type of field. +Blockly.Msg.ARIA_TYPE_FIELD_TEXT_INPUT = 'text'; +/** @type {string} */ +/// ARIA type name for a number field, used by screen readers to identify the type of field. +Blockly.Msg.ARIA_TYPE_FIELD_NUMBER = 'number'; +/** @type {string} */ +/// 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} */ /// 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 +Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1'; +/** @type {string} */ +/// 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 diff --git a/packages/blockly/tests/mocha/field_dropdown_test.js b/packages/blockly/tests/mocha/field_dropdown_test.js index a1731e81281..8dccdf98bd9 100644 --- a/packages/blockly/tests/mocha/field_dropdown_test.js +++ b/packages/blockly/tests/mocha/field_dropdown_test.js @@ -325,4 +325,261 @@ suite('Dropdown Fields', function () { this.assertValue('C', field); }); }); + + suite('ARIA', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + }); + suite('Simple Dropdown', function () { + setup(function () { + const block = this.workspace.newBlock('logic_boolean'); + this.field = block.getField('BOOL'); + block.initSvg(); + block.render(); + + this.focusableElement = this.field.getFocusableElement(); + }); + test('Block has field type name in ARIA label', function () { + const blockLabel = this.field.getSourceBlock().getAriaLabel(); + assert.include(blockLabel, 'dropdown:'); + }); + 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('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.field.getSourceBlock().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.field.getSourceBlock().workspace.hideChaff(); + }); + test('Has placeholder ARIA label by default', function () { + const label = this.focusableElement.getAttribute('aria-label'); + assert.include(label, 'true'); + }); + test('setValue updates ARIA label', function () { + const initialLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(initialLabel, 'true'); + this.field.setValue('FALSE'); + console.log(this.field.getOptions()); + const updatedLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(updatedLabel, 'false'); + }); + }); + suite('Dropdown with Option ARIA labels', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'math_op', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'OP', + 'options': [ + ['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD', 'Plus'], + ['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS', 'Minus'], + ['%{BKY_MATH_MULTIPLICATION_SYMBOL}', 'MULTIPLY', 'Times'], + ['%{BKY_MATH_DIVISION_SYMBOL}', 'DIVIDE', 'Divided by'], + ['%{BKY_MATH_POWER_SYMBOL}', 'POWER', 'To the power of'], + ], + }, + ], + }, + ]); + const block = this.workspace.newBlock('math_op'); + block.initSvg(); + block.render(); + this.field = block.getField('OP'); + }); + test('Option ARIA labels are included in field ARIA label', function () { + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.include(label, 'Plus'); + }); + test('Option ARIA labels are included in field ARIA label when value is changed', function () { + this.field.setValue('DIVIDE'); + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.include(label, 'Divided by'); + }); + }); + suite('Dropdown with image options', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'image_dropdown_test', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'IMG', + 'options': [ + [ + { + 'src': + 'https://blockly-demo.appspot.com/static/tests/media/a.png', + 'width': 32, + 'height': 32, + 'alt': 'A', + }, + 'A', + ], + [ + { + 'src': + 'https://blockly-demo.appspot.com/static/tests/media/b.png', + 'width': 32, + 'height': 32, + 'alt': 'B', + 'ariaLabel': 'Letter B', + }, + 'B', + ], + ], + }, + ], + }, + ]); + const block = this.workspace.newBlock('image_dropdown_test'); + block.initSvg(); + block.render(); + this.field = block.getField('IMG'); + }); + test('Image alt text is included in ARIA label', function () { + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.equal(label, 'A'); + }); + test('Image ARIA label is prioritized over alt text', function () { + this.field.dropdownCreate(); + this.field.setValue('B'); + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.equal(label, 'Letter B'); + }); + }); + suite('Dropdown with HTMLElement options', function () { + setup(function () { + function makeElementOption({ariaLabel, title, innerText}) { + const element = document.createElement('div'); + if (ariaLabel) element.ariaLabel = ariaLabel; + if (title) element.title = title; + if (innerText) element.innerText = innerText; + return element; + } + const options = [ + [ + makeElementOption({ + ariaLabel: 'Ignored', + title: 'Ignored', + innerText: 'Ignored', + }), + 'A', + 'Explicit A label', + ], + [ + makeElementOption({ + ariaLabel: 'Element ARIA', + title: 'Ignored', + innerText: 'Ignored', + }), + 'B', + ], + [ + makeElementOption({ + title: 'Title text', + innerText: 'Ignored', + }), + 'C', + ], + [makeElementOption({innerText: 'Inner text'}), 'D'], + [makeElementOption({}), 'E'], + ]; + + Blockly.Blocks['aria_dropdown_test'] = { + init: function () { + this.appendDummyInput().appendField( + new Blockly.FieldDropdown(options), + 'OP', + ); + + this.setOutput(true, null); + this.setColour(230); + }, + }; + const block = this.workspace.newBlock('aria_dropdown_test'); + block.initSvg(); + block.render(); + this.field = block.getField('OP'); + }); + test('Explicit ARIA label overrides all other label sources', function () { + this.field.setValue('A'); + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.equal(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'); + }); + 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'); + }); + 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'); + }); + 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'); + }); + }); + }); }); diff --git a/packages/blockly/tests/mocha/field_number_test.js b/packages/blockly/tests/mocha/field_number_test.js index 692633bfa3d..8f405de9eef 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -514,6 +514,10 @@ suite('Number Fields', function () { this.focusableElement = this.field.getClickTarget_(); }); + test('Block has field type name in ARIA label', function () { + const blockLabel = this.field.getSourceBlock().getAriaLabel(); + assert.include(blockLabel, '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 0ab0c745296..d2b066ebf94 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -605,6 +605,10 @@ suite('Text Input Fields', function () { this.focusableElement = this.field.getClickTarget_(); }); + test('Block has field type name in ARIA label', function () { + const blockLabel = this.field.getSourceBlock().getAriaLabel(); + assert.include(blockLabel, 'text:'); + }); test('Focusable element has role of button', function () { const role = this.focusableElement.getAttribute('role'); assert.equal(role, 'button'); From d9be0849930c10bb62bd94e287317e8719c16a7c Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:20:18 -0400 Subject: [PATCH 2/3] fix: clean up tests --- .../blockly/tests/mocha/field_dropdown_test.js | 17 ++++++++--------- .../blockly/tests/mocha/field_number_test.js | 12 ++++++------ .../blockly/tests/mocha/field_textinput_test.js | 12 ++++++------ 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/packages/blockly/tests/mocha/field_dropdown_test.js b/packages/blockly/tests/mocha/field_dropdown_test.js index 8dccdf98bd9..c8d02c480e4 100644 --- a/packages/blockly/tests/mocha/field_dropdown_test.js +++ b/packages/blockly/tests/mocha/field_dropdown_test.js @@ -334,15 +334,15 @@ suite('Dropdown Fields', function () { }); suite('Simple Dropdown', function () { setup(function () { - const block = this.workspace.newBlock('logic_boolean'); - this.field = block.getField('BOOL'); - block.initSvg(); - block.render(); + this.block = this.workspace.newBlock('logic_boolean'); + this.field = this.block.getField('BOOL'); + this.block.initSvg(); + this.block.render(); this.focusableElement = this.field.getFocusableElement(); }); test('Block has field type name in ARIA label', function () { - const blockLabel = this.field.getSourceBlock().getAriaLabel(); + const blockLabel = this.block.getAriaLabel(); assert.include(blockLabel, 'dropdown:'); }); test('Focusable element has role of button', function () { @@ -350,7 +350,7 @@ suite('Dropdown Fields', function () { assert.equal(role, 'button'); }); test('Hidden when in a flyout', function () { - this.field.getSourceBlock().isInFlyout = true; + this.block.isInFlyout = true; // Force recompute of ARIA label. this.field.setValue(this.field.getValue()); const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); @@ -366,7 +366,7 @@ suite('Dropdown Fields', function () { const ariaExpanded = this.focusableElement.getAttribute('aria-expanded'); assert.equal(ariaExpanded, 'true'); - this.field.getSourceBlock().workspace.hideChaff(); + this.workspace.hideChaff(); }); test('Has aria-haspopup of listbox', function () { const ariaHasPopup = @@ -379,7 +379,7 @@ suite('Dropdown Fields', function () { this.focusableElement.getAttribute('aria-controls'); const menuId = this.field.menu_.id; assert.equal(ariaControls, menuId); - this.field.getSourceBlock().workspace.hideChaff(); + this.workspace.hideChaff(); }); test('Has placeholder ARIA label by default', function () { const label = this.focusableElement.getAttribute('aria-label'); @@ -389,7 +389,6 @@ suite('Dropdown Fields', function () { const initialLabel = this.focusableElement.getAttribute('aria-label'); assert.include(initialLabel, 'true'); this.field.setValue('FALSE'); - console.log(this.field.getOptions()); const updatedLabel = this.focusableElement.getAttribute('aria-label'); assert.include(updatedLabel, 'false'); }); diff --git a/packages/blockly/tests/mocha/field_number_test.js b/packages/blockly/tests/mocha/field_number_test.js index 8f405de9eef..918bf3917e3 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -507,15 +507,15 @@ suite('Number Fields', 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.block = this.workspace.newBlock('math_number'); + this.field = this.block.getField('NUM'); + this.block.initSvg(); + this.block.render(); this.focusableElement = this.field.getClickTarget_(); }); test('Block has field type name in ARIA label', function () { - const blockLabel = this.field.getSourceBlock().getAriaLabel(); + const blockLabel = this.block.getAriaLabel(); assert.include(blockLabel, 'number:'); }); test('Focusable element has role of button', function () { @@ -523,7 +523,7 @@ suite('Number Fields', function () { assert.equal(role, 'button'); }); test('Hidden when in a flyout', function () { - this.field.getSourceBlock().isInFlyout = true; + this.block.isInFlyout = true; // Force recompute of ARIA label. this.field.setValue(this.field.getValue()); const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); diff --git a/packages/blockly/tests/mocha/field_textinput_test.js b/packages/blockly/tests/mocha/field_textinput_test.js index d2b066ebf94..ab3fca35998 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -598,15 +598,15 @@ suite('Text Input Fields', function () { this.workspace = Blockly.inject('blocklyDiv', { renderer: 'geras', }); - const block = this.workspace.newBlock('text'); - this.field = block.getField('TEXT'); - block.initSvg(); - block.render(); + this.block = this.workspace.newBlock('text'); + this.field = this.block.getField('TEXT'); + this.block.initSvg(); + this.block.render(); this.focusableElement = this.field.getClickTarget_(); }); test('Block has field type name in ARIA label', function () { - const blockLabel = this.field.getSourceBlock().getAriaLabel(); + const blockLabel = this.block.getAriaLabel(); assert.include(blockLabel, 'text:'); }); test('Focusable element has role of button', function () { @@ -614,7 +614,7 @@ suite('Text Input Fields', function () { assert.equal(role, 'button'); }); test('Hidden when in a flyout', function () { - this.field.getSourceBlock().isInFlyout = true; + this.block.isInFlyout = true; // Force recompute of ARIA label. this.field.setValue(this.field.getValue()); const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); From a260e2bd3c3daaa720c939015356a9b81b4dc923 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:27:42 -0400 Subject: [PATCH 3/3] fix: code review --- packages/blockly/core/field_dropdown.ts | 5 +++++ packages/blockly/core/menu.ts | 5 +---- packages/blockly/core/menuitem.ts | 5 +---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/blockly/core/field_dropdown.ts b/packages/blockly/core/field_dropdown.ts index 3176f4dd649..64ecd39a9c5 100644 --- a/packages/blockly/core/field_dropdown.ts +++ b/packages/blockly/core/field_dropdown.ts @@ -99,6 +99,10 @@ export class FieldDropdown extends Field { /** The total vertical padding above and below an image. */ protected static IMAGE_Y_PADDING = FieldDropdown.IMAGE_Y_OFFSET * 2; + /** + * True once the field’s DOM has been created and it is safe to run ARIA + * updates in response to value changes. + */ isInitialized: boolean = false; /** @@ -858,6 +862,7 @@ export class FieldDropdown extends Field { throw TypeError('Found invalid FieldDropdown options.'); } } + /** * Gets an ARIA-friendly label representation of this field's type. * diff --git a/packages/blockly/core/menu.ts b/packages/blockly/core/menu.ts index 8fb2849778a..8d7585681df 100644 --- a/packages/blockly/core/menu.ts +++ b/packages/blockly/core/menu.ts @@ -64,7 +64,7 @@ export class Menu { private roleName: aria.Role | null = null; /** The menu's ID. */ - private id: string | null = null; + private id: string = idGenerator.getNextUniqueId(); /** Constructs a new Menu instance. */ constructor() {} @@ -490,9 +490,6 @@ export class Menu { } getId(): string { - if (!this.id) { - this.id = idGenerator.getNextUniqueId(); - } return this.id; } } diff --git a/packages/blockly/core/menuitem.ts b/packages/blockly/core/menuitem.ts index f3eb48b0d3c..9500e322c56 100644 --- a/packages/blockly/core/menuitem.ts +++ b/packages/blockly/core/menuitem.ts @@ -44,7 +44,7 @@ export class MenuItem { null; /** The unique ID for this menu item. */ - private id: string | null = null; + private id: string = idGenerator.getNextUniqueId(); /** * @param content Text caption to display as the content of the item, or a @@ -139,9 +139,6 @@ export class MenuItem { * @internal */ getId(): string { - if (!this.id) { - this.id = idGenerator.getNextUniqueId(); - } return this.id; }