From dd79592039a7beefe244ab1da728a9c29017ca6d Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Wed, 24 Jun 2026 11:07:50 +0100 Subject: [PATCH] fix: Treat the flyout as a listbox with options --- packages/blockly/core/block_flyout_inflater.ts | 16 +++++++++++++++- packages/blockly/core/flyout_base.ts | 4 ++-- packages/blockly/core/flyout_button.ts | 4 ++-- packages/blockly/core/icons/icon.ts | 10 ++++++++++ packages/blockly/tests/mocha/aria_test.js | 2 +- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/blockly/core/block_flyout_inflater.ts b/packages/blockly/core/block_flyout_inflater.ts index 710e4efd30e..f8e09ddd790 100644 --- a/packages/blockly/core/block_flyout_inflater.ts +++ b/packages/blockly/core/block_flyout_inflater.ts @@ -80,7 +80,21 @@ export class BlockFlyoutInflater implements IFlyoutInflater { // to correct the role and hidden state for it. const focusableElement = block.getFocusableElement(); aria.clearState(focusableElement, aria.State.HIDDEN); - aria.setRole(focusableElement, aria.Role.LISTITEM); + aria.setRole(focusableElement, aria.Role.OPTION); + + // Clickable icons in the flyout are owned by their parent block. + // This ensures that clickable icons are not included in the option + // count when screen readers assess the number of options in the + // flyout listbox. + const ownedIconIds = block + .getIcons() + .filter((icon) => icon.isClickableInFlyout?.(flyout.autoClose)) + .map((icon) => icon.getFocusableElement().id) + .filter((id) => !!id); + if (ownedIconIds.length) { + aria.setState(focusableElement, aria.State.OWNS, ownedIconIds); + } + this.addBlockListeners(block); return new FlyoutItem(block, BLOCK_TYPE); diff --git a/packages/blockly/core/flyout_base.ts b/packages/blockly/core/flyout_base.ts index c973b1775e1..36380433735 100644 --- a/packages/blockly/core/flyout_base.ts +++ b/packages/blockly/core/flyout_base.ts @@ -697,9 +697,9 @@ export abstract class Flyout .trim(); aria.setState(this.getWorkspace().getCanvas(), aria.State.LABEL, ariaLabel); - // The block canvas is a list. The list items must be direct descendants of the list, + // The block canvas is a listbox. The options must be direct descendants of the listbox, // and the flyout may or may not be a region, so we set the role on the block canvas rather than the svgGroup_. - aria.setRole(this.getWorkspace().getCanvas(), aria.Role.LIST); + aria.setRole(this.getWorkspace().getCanvas(), aria.Role.LISTBOX); } /** diff --git a/packages/blockly/core/flyout_button.ts b/packages/blockly/core/flyout_button.ts index 3083fa8e19e..6fcb60bbaa7 100644 --- a/packages/blockly/core/flyout_button.ts +++ b/packages/blockly/core/flyout_button.ts @@ -177,10 +177,10 @@ export class FlyoutButton aria.setRole(svgText, aria.Role.PRESENTATION); // We add the word "heading" or "button" to the label so that they give appropriate hints - // we can't use the corresponding roles because that overwrites the context of it being a list item. + // we can't use the corresponding roles because that overwrites the context of it being an option. const ariaLabel = `${text}, ${this.isFlyoutLabel ? Msg['ARIA_LABEL_HEADING'] : Msg['ARIA_LABEL_BUTTON']}`; aria.setState(this.getFocusableElement(), aria.State.LABEL, ariaLabel); - aria.setRole(this.getFocusableElement(), aria.Role.LISTITEM); + aria.setRole(this.getFocusableElement(), aria.Role.OPTION); const fontSize = style.getComputedStyle(svgText, 'fontSize'); const fontWeight = style.getComputedStyle(svgText, 'fontWeight'); diff --git a/packages/blockly/core/icons/icon.ts b/packages/blockly/core/icons/icon.ts index fefd320b334..2288cef3527 100644 --- a/packages/blockly/core/icons/icon.ts +++ b/packages/blockly/core/icons/icon.ts @@ -231,6 +231,16 @@ export abstract class Icon implements IIcon, IContextMenu { protected recomputeAriaContext(): void { const element = this.getFocusableElement(); if (!element) return; + const flyout = ( + this.sourceBlock.workspace as WorkspaceSvg + ).targetWorkspace?.getFlyout(); + if (flyout && !this.isClickableInFlyout(flyout.autoClose)) { + // Icons that can't be used in the flyout are removed from the + // accessibility tree, like non-interactive fields. + aria.setState(element, aria.State.HIDDEN, true); + return; + } + aria.clearState(element, aria.State.HIDDEN); aria.setRole(element, aria.Role.BUTTON); const label = this.getAriaLabel() ?? Msg['ICON_LABEL_DEFAULT']; aria.setState(element, aria.State.LABEL, label); diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 54d5b738905..20bb35aa5c6 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -348,7 +348,7 @@ suite('ARIA', function () { ); const block = this.workspace.getFlyout().getWorkspace().getTopBlocks()[0]; const role = Blockly.utils.aria.getRole(block.getFocusableElement()); - assert.equal(role, Blockly.utils.aria.Role.LISTITEM); + assert.equal(role, Blockly.utils.aria.Role.OPTION); }); test('Root workspace blocks indicate that in their labels', function () {