From 5b8dba3873b5ac109bfca0a9570910ebd17ef23a Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Thu, 7 May 2026 15:09:56 -0400 Subject: [PATCH] fix: improve block labels and aria roles --- packages/blockly/core/block.ts | 25 +++++- packages/blockly/core/block_aria_composer.ts | 22 ++++-- packages/blockly/core/block_svg.ts | 6 ++ packages/blockly/core/field.ts | 79 +++++++++++++++++-- packages/blockly/core/field_checkbox.ts | 14 ++-- packages/blockly/core/field_dropdown.ts | 20 ++--- packages/blockly/core/field_image.ts | 28 +++---- packages/blockly/core/field_input.ts | 24 ++---- packages/blockly/tests/mocha/aria_test.js | 19 +---- .../tests/mocha/field_dropdown_test.js | 4 - .../blockly/tests/mocha/field_image_test.js | 20 ++--- .../blockly/tests/mocha/field_number_test.js | 4 - .../tests/mocha/field_textinput_test.js | 4 - .../tests/mocha/field_variable_test.js | 4 +- packages/blockly/tests/mocha/input_test.js | 7 +- .../tests/mocha/keyboard_movement_test.js | 2 +- 16 files changed, 166 insertions(+), 116 deletions(-) diff --git a/packages/blockly/core/block.ts b/packages/blockly/core/block.ts index 4690229d439..777c4ca6d28 100644 --- a/packages/blockly/core/block.ts +++ b/packages/blockly/core/block.ts @@ -967,7 +967,30 @@ export class Block { } /** - * @returns True if this block is a value block with a single editable field. + * Determines and returns the full-block field for this block, or null if there isn't one + * and this block can't be considered a singleton field block. + * + * Note that this method is unreliable if a block contains a single field that + * hasn't been initialized/rendered yet. + * + * @returns The full-block field this block contains, or null if it doesn't contain one. + * @internal + */ + getFullBlockField(): Field | null { + if (!this.isSimpleReporter()) return null; + const field = this.inputList[0]?.fieldRow[0]; + return field?.isFullBlockField() ? field : null; + } + + /** + * A block is a simple reporter if it has an output connection and exactly one field. + * In some renderers, simple reporters are rendered differently from other blocks. + * Being a simple reporter block is a prerequisite to the single field rendering itself + * as a "full-block field", but it is not sufficient, as not all fields or renderers use + * this special rendering. Use `getFullBlockField` to determine if the block is rendered + * as a "full-block field block". + * + * @returns True if this block is a value block with a single field. * @internal */ isSimpleReporter(): boolean { diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index aa9cb6db799..d236049d751 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -57,6 +57,13 @@ export function computeAriaLabel( block: BlockSvg, verbosity = Verbosity.STANDARD, ) { + if (block.isSimpleReporter()) { + // special case for full-block field blocks. + const field = block.getFullBlockField(); + if (field) { + return field.computeAriaLabel(verbosity >= Verbosity.STANDARD); + } + } return [ verbosity >= Verbosity.STANDARD && getBeginStackLabel(block), getParentInputLabel(block), @@ -64,7 +71,7 @@ export function computeAriaLabel( verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), verbosity >= Verbosity.STANDARD && getDisabledLabel(block), verbosity >= Verbosity.STANDARD && getCollapsedLabel(block), - verbosity >= Verbosity.STANDARD && getShadowBlockLabel(block), + verbosity >= Verbosity.LOQUACIOUS && getShadowBlockLabel(block), verbosity >= Verbosity.STANDARD && getInputCountLabel(block), ] .filter((label) => !!label) @@ -123,7 +130,7 @@ export function computeFieldRowLabel( lookback: boolean, verbosity = Verbosity.STANDARD, ): string[] { - const includeTypeInfo = verbosity >= Verbosity.STANDARD; + const includeTypeInfo = verbosity >= Verbosity.LOQUACIOUS; const fieldRowLabel = input.fieldRow .filter((field) => field.isVisible()) .map((field) => field.computeAriaLabel(includeTypeInfo)); @@ -181,7 +188,10 @@ function getParentInputLabel(block: BlockSvg) { * does not. */ function getBeginStackLabel(block: BlockSvg) { - return !block.workspace.isFlyout && block.getRootBlock() === block + // Don't include the "begin stack" label for blocks that are moving + // or blocks in the flyout + if (block.isInFlyout || block.workspace.isDragging()) return undefined; + return block.getRootBlock() === block ? Msg['BLOCK_LABEL_BEGIN_STACK'] : undefined; } @@ -204,11 +214,7 @@ export function getInputLabels( ): string[] { return block.inputList .filter((input) => input.isVisible()) - .map((input) => - input.getAriaLabelText() !== null - ? input.getAriaLabelText()! - : input.getLabel(verbosity), - ); + .map((input) => input.getLabel(verbosity)); } /** diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index dba65a6b34d..6d160fbd783 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -1881,6 +1881,11 @@ export class BlockSvg /** See IFocusableNode.getFocusableElement. */ getFocusableElement(): HTMLElement | SVGElement { + // For full-block fields, we focus the field itself + const fullBlockField = this.getFullBlockField(); + if (fullBlockField) { + return fullBlockField.getFocusableElement(); + } return this.pathObject.svgPath; } @@ -1998,6 +2003,7 @@ export class BlockSvg * Updates the ARIA label, role and roledescription for this block. */ private recomputeAriaAttributes() { + if (this.getFullBlockField()) return; aria.setState( this.getFocusableElement(), aria.State.LABEL, diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index 1de0d789954..54a49fbde4c 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -32,6 +32,7 @@ import {Msg} from './msg.js'; import type {ConstantProvider} from './renderers/common/constants.js'; import type {KeyboardShortcut} from './shortcut_registry.js'; import * as Tooltip from './tooltip.js'; +import * as aria from './utils/aria.js'; import type {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as idGenerator from './utils/idgenerator.js'; @@ -275,7 +276,6 @@ export abstract class Field `problems with focus: ${block.id}.`, ); } - this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; } /** @@ -398,11 +398,7 @@ export abstract class Field // Field has already been initialized once. return; } - const id = this.id_; - if (!id) throw new Error('Expected ID to be defined prior to init.'); - this.fieldGroup_ = dom.createSvgElement(Svg.G, { - 'id': id, - }); + this.fieldGroup_ = dom.createSvgElement(Svg.G, {}); if (!this.isVisible()) { this.fieldGroup_.style.display = 'none'; } @@ -414,6 +410,15 @@ export abstract class Field this.bindEvents_(); this.initModel(); this.applyColour(); + + // Since full-block fields can be focused from the workspace's tree, + // they need IDs in the format that the workspace is expecting. + if (this.isFullBlockField()) { + this.id_ = idGenerator.getNextUniqueId(); + } else { + this.id_ = `${sourceBlockSvg.id}_field_${idGenerator.getNextUniqueId()}`; + } + this.fieldGroup_.setAttribute('id', this.id_); } /** @@ -1492,6 +1497,68 @@ export abstract class Field this.showEditor(); } + /** + * Recomputes the aria state and label for this field. Fields are generally hidden + * when in blocks in the flyout (except for top-level full-block fields), and + * otherwise set to a role of button (indicating they can be clicked to edit) + * and given the label returned from their `computeAriaLabel` method. + * + * Subclasses can override this in order to change the role or label, but they must + * ensure they keep the correct behavior for fields in flyout blocks. + * + * This method will return a boolean indicating if the element is displayed in the + * aria tree or not. This can be used by subclasses to determine whether or not + * to continue customizing the role and label (hidden elements should not have labels). + * + * @returns true if the element is in the accessibility tree, false if the aria state is hidden + */ + protected recomputeAriaContext(): boolean { + let focusableElement; + try { + focusableElement = this.getFocusableElement(); + } catch { + // just return because the field hasn't been init yet + return false; + } + + if (!focusableElement) return false; + + if (this.getSourceBlock()?.isInFlyout) { + const isTopLevelFullBlockField = + this.getSourceBlock()?.getFullBlockField() && + !this.getSourceBlock()?.getParent(); + if (!isTopLevelFullBlockField) { + // Fields in the flyout are not generally focusable, so they should + // be hidden. An exception is full-block field blocks that don't have + // parents, since the block itself defers to the field's focusable element. + aria.setState(focusableElement, aria.State.HIDDEN, true); + return false; + } else { + // top-level full-block fields in the flyout need to have their + // roledescription set. this can't happen in the flyout code because + // the field hasn't been initialized yet then. + // these blocks should also have the rest of the state in this method set. + const roleDescription = + this.getSourceBlock()?.getAriaRoleDescription() || + Msg['BLOCK_LABEL_VALUE']; + aria.setState( + focusableElement, + aria.State.ROLEDESCRIPTION, + roleDescription, + ); + } + } + + aria.clearState(focusableElement, aria.State.HIDDEN); + // 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(true); + aria.setState(focusableElement, aria.State.LABEL, label); + return true; + } + /** * Subclasses should reimplement this method to construct their Field * subclass from a JSON arg object. diff --git a/packages/blockly/core/field_checkbox.ts b/packages/blockly/core/field_checkbox.ts index f09c579bea2..60e00d89462 100644 --- a/packages/blockly/core/field_checkbox.ts +++ b/packages/blockly/core/field_checkbox.ts @@ -267,16 +267,13 @@ export class FieldCheckbox extends Field { } /** - * Recomputes the ARIA role and label for this field. + * Customizes the label and sets additional aria state. */ - protected recomputeAriaContext(): void { - const focusableElement = this.getClickTarget_(); - if (!focusableElement) return; + override recomputeAriaContext(): boolean { + const shouldCustomize = super.recomputeAriaContext(); + if (!shouldCustomize) return false; - if (this.getSourceBlock()?.isInFlyout) { - aria.setState(focusableElement, aria.State.HIDDEN, true); - return; - } + const focusableElement = this.getFocusableElement(); aria.setState(focusableElement, aria.State.HIDDEN, false); aria.setRole(focusableElement, aria.Role.CHECKBOX); @@ -289,6 +286,7 @@ export class FieldCheckbox extends Field { const label = this.getAriaTypeName(); aria.setState(focusableElement, aria.State.LABEL, label); + return true; } } diff --git a/packages/blockly/core/field_dropdown.ts b/packages/blockly/core/field_dropdown.ts index 136c36f69ad..d7699606b22 100644 --- a/packages/blockly/core/field_dropdown.ts +++ b/packages/blockly/core/field_dropdown.ts @@ -918,27 +918,19 @@ export class FieldDropdown extends Field { } /** - * Recomputes the ARIA role and label for this field. + * Overrides the default label and sets additional aria state. */ - protected 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); + override recomputeAriaContext(): boolean { + const shouldCustomize = super.recomputeAriaContext(); + if (!shouldCustomize) return false; + const focusableElement = this.getFocusableElement(); const label = this.computeAriaLabel(true); aria.setState(focusableElement, aria.State.LABEL, label); aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox'); aria.setState(focusableElement, aria.State.EXPANDED, !!this.menu_); + return true; } /** diff --git a/packages/blockly/core/field_image.ts b/packages/blockly/core/field_image.ts index 4dce9bb0066..7c34ac2154b 100644 --- a/packages/blockly/core/field_image.ts +++ b/packages/blockly/core/field_image.ts @@ -316,30 +316,24 @@ export class FieldImage extends Field { } /** - * Recomputes the ARIA role and label for this field. + * Customizes label and sets additional aria state. */ - protected recomputeAriaContext(): void { - const focusableElement = this.getClickTarget_(); - if (!focusableElement) return; + override recomputeAriaContext(): boolean { + const shouldCustomize = super.recomputeAriaContext(); + if (!shouldCustomize) return false; - const isInFlyout = this.getSourceBlock()?.isInFlyout; - if (isInFlyout) { - aria.setState(focusableElement, aria.State.HIDDEN, true); - return; - } + const focusableElement = this.getFocusableElement(); - 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); + if (!this.isClickable()) { + aria.setRole(focusableElement, aria.Role.PRESENTATION); + aria.clearState(focusableElement, aria.State.LABEL); + return false; + } + return true; } } diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index 892f8abf0b5..48404b357f5 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -837,29 +837,19 @@ export abstract class FieldInput extends Field< } /** - * Recomputes the ARIA role and label for this field. + * Customizes the label for this field to include "editable" if it applies. */ - 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); - // 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); + override recomputeAriaContext(): boolean { + const shouldCustomize = super.recomputeAriaContext(); + if (!shouldCustomize) return false; + const focusableElement = this.getFocusableElement(); let label = this.computeAriaLabel(true); - - if (this.isCurrentlyEditable?.()) { + if (this.isCurrentlyEditable() && !this.getSourceBlock()?.isInFlyout) { label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label); } - aria.setState(focusableElement, aria.State.LABEL, label); + return true; } } diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 87f9e77e83c..39d540d0f3f 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -425,25 +425,8 @@ suite('ARIA', function () { assert.include(label, 'collapsed'); }); - test('Shadow blocks indicate that in their label', function () { - const block = this.makeBlock('text_print'); - const text = this.makeBlock('text'); - text.outputConnection.connect(block.inputList[0].connection); - let label = Blockly.utils.aria.getState( - text.getFocusableElement(), - Blockly.utils.aria.State.LABEL, - ); - assert.notInclude(label, 'replaceable'); - text.setShadow(true); - label = Blockly.utils.aria.getState( - text.getFocusableElement(), - Blockly.utils.aria.State.LABEL, - ); - assert.include(label, 'replaceable'); - }); - test('Blocks without inputs are properly labeled', function () { - const block = this.makeBlock('math_random_float'); + const block = this.makeBlock('logic_boolean'); const label = Blockly.utils.aria.getState( block.getFocusableElement(), Blockly.utils.aria.State.LABEL, diff --git a/packages/blockly/tests/mocha/field_dropdown_test.js b/packages/blockly/tests/mocha/field_dropdown_test.js index 37af2b277e6..cae3fb4d0fb 100644 --- a/packages/blockly/tests/mocha/field_dropdown_test.js +++ b/packages/blockly/tests/mocha/field_dropdown_test.js @@ -341,10 +341,6 @@ suite('Dropdown Fields', function () { this.focusableElement = this.field.getFocusableElement(); }); - test('Block has field type name in ARIA label', 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:'); diff --git a/packages/blockly/tests/mocha/field_image_test.js b/packages/blockly/tests/mocha/field_image_test.js index c4150d42ae7..6de4e9bf4fc 100644 --- a/packages/blockly/tests/mocha/field_image_test.js +++ b/packages/blockly/tests/mocha/field_image_test.js @@ -362,19 +362,11 @@ suite('Image Fields', function () { 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 () { + test('Focusable element has role of presentation', function () { const role = this.focusableElement.getAttribute('role'); assert.equal(role, 'presentation'); }); @@ -387,6 +379,16 @@ suite('Image Fields', function () { }); }); suite('Image with click handler', function () { + test('Field has field type name in ARIA label', function () { + const block = this.workspace.newBlock('test_images_clickhandler'); + const field = block.getField('IMAGE'); + block.initSvg(); + block.render(); + + const focusableElement = field.getFocusableElement(); + const fieldLabel = focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'image:'); + }); test('Focusable element has role of button', function () { const block = this.workspace.newBlock('test_images_clickhandler'); const field = block.getField('IMAGE'); diff --git a/packages/blockly/tests/mocha/field_number_test.js b/packages/blockly/tests/mocha/field_number_test.js index f040b55977d..59d82b4b141 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -514,10 +514,6 @@ suite('Number Fields', function () { this.focusableElement = this.field.getClickTarget_(); }); - test('Block has field type name in ARIA label', 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:'); diff --git a/packages/blockly/tests/mocha/field_textinput_test.js b/packages/blockly/tests/mocha/field_textinput_test.js index 5a1191435a4..48e31d13039 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -605,10 +605,6 @@ suite('Text Input Fields', function () { this.focusableElement = this.field.getClickTarget_(); }); - test('Block has field type name in ARIA label', 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:'); diff --git a/packages/blockly/tests/mocha/field_variable_test.js b/packages/blockly/tests/mocha/field_variable_test.js index c0cdcb6693e..06ca1f5b7e4 100644 --- a/packages/blockly/tests/mocha/field_variable_test.js +++ b/packages/blockly/tests/mocha/field_variable_test.js @@ -661,9 +661,9 @@ suite('Variable Fields', function () { this.focusableElement = this.field.getFocusableElement(); }); - test('Block has dropdown field type name and "Variable" qualifier in ARIA label', function () { + test('Block has "Variable" qualifier in ARIA label', function () { const blockLabel = this.block.getAriaLabel(); - assert.include(blockLabel, 'dropdown:'); + assert.include(blockLabel, 'Variable'); }); test('Field has dropdown field type name and "Variable" qualifier in ARIA label', function () { const fieldLabel = this.focusableElement.getAttribute('aria-label'); diff --git a/packages/blockly/tests/mocha/input_test.js b/packages/blockly/tests/mocha/input_test.js index 075ea548d3d..48c186107bd 100644 --- a/packages/blockly/tests/mocha/input_test.js +++ b/packages/blockly/tests/mocha/input_test.js @@ -310,7 +310,8 @@ suite('Inputs', function () { }, ]); }); - test('Set input ARIA Label Provider', function () { + // Temporarily skipping while we debate custom inputs aria behavior + test.skip('Set input ARIA Label Provider', function () { const customLabel = 'custom ARIA label'; // Using a text input as it will return a default ARIA label this.block @@ -323,7 +324,7 @@ suite('Inputs', function () { assert.include(label, customLabel); assert.notInclude(label, 'text'); }); - test('Set input ARIA Label Provider from JSON', function () { + test.skip('Set input ARIA Label Provider from JSON', function () { const customLabel = 'custom ARIA label'; Blockly.defineBlocksWithJsonArray([ { @@ -349,7 +350,7 @@ suite('Inputs', function () { assert.include(label, customLabel); }); - test('Set input ARIA Label Provider to null', function () { + test.skip('Set input ARIA Label Provider to null', function () { const blockA = createRenderedBlock(this.workspace, 'row_block'); const blockB = createRenderedBlock(this.workspace, 'row_block'); diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index 1bbb4b94542..a6edc06d0b5 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -1352,7 +1352,7 @@ suite('Keyboard-driven movement', function () { suite('of bubbles', function () { setup(async function () { - const commentBlock = this.workspace.newBlock('logic_boolean'); + const commentBlock = this.workspace.newBlock('logic_compare'); commentBlock.setCommentText('Hello world'); const icon = commentBlock.getIcon(Blockly.icons.IconType.COMMENT); await icon.setBubbleVisible(true);