From bb342f9644683c86aea3fee43df2f0f32a3e0445 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 15 Dec 2025 10:13:23 -0800 Subject: [PATCH] feat: Make `Flyout` an ARIA `list` (experimental) (#9528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9495 ### Proposed Changes Changes a bunch of ARIA role & label management to ensure that `Flyout` acts like a list rather than a tree. ### Reason for Changes `Flyout`s are always hierarchically flat so it doesn't make sense to model them as a tree. Instead, a menu is likely a better fit per https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/menu_role: > A `menu` generally represents a grouping of common actions or functions that the user can invoke. The `menu` role is appropriate when a list of menu items is presented in a manner similar to a menu on a desktop application. Submenus, also known as pop-up menus, also have the role `menu`. However, there are important caveats that need to be considered and addressed: - As discussed below, menus introduce some unexpected compatibility issues with VoiceOver so this PR presently uses `list` and `listitem`s as a slightly more generic enumerating alternative for menus. - Menus (and to some extent lists) are stricter\* than trees in that they seem to impose a requirement that `menuitem`s cannot contain interactive elements (they are expected to be interactive themselves). This has led to a few specific changes: - Block children are now hidden when in the flyout (since they aren't navigable anyway). - Flyout buttons are themselves now the `menuitem` rather than their container parent, and they no longer use the role button. - Menus aren't really expected to contain labels but it isn't inherently disallowed. This is less of an issue with lists. - Because everything must be a `listitem` (or a few more specific alternatives) both blocks and buttons lack some context. Since not all `Flyout` items can be expected to be interactive, buttons and blocks have both had their labels updated to include an explicit indicator that they are buttons and blocks, respectively. Note that this does possibly go against convention for buttons in particular but it seems fine since this is an unusual (but seemingly correct) utilization of a `list` element. - To further provide context on blocks, the generated label for blocks in the `Flyout` is now its verbose label rather than the more compact form. \* This is largely a consequence of a few specific attributes of `menuitem` and `menu`s as a whole and very likely also applies to `tree`s and `treeitem`s (and `list`s and `listitems`s). However, now seemed like a good time to fix this especially in case some screen readers get confused rather than ignore nested interactive controls/follow semantic cloaking per the spec. Demo of it working on VoiceOver (per @gonfunko -- note this was the `menu` variant rather than the `list` variant of the PR): ![Screen Recording 2025-12-11 at 2 50 30 PM](https://github.com/user-attachments/assets/24c4389f-73c7-4cb5-96ce-d9666841cdd8) ### Test Coverage This has been manually tested with ChromeVox. No automated tests are needed as part of this experimental change. ### Documentation No new documentation changes are needed for this experimental change. ### Additional Information None. --- core/block_svg.ts | 10 ++++++++-- core/field_checkbox.ts | 19 ++++++++++++------- core/field_dropdown.ts | 20 ++++++++++++-------- core/field_image.ts | 5 +++-- core/field_input.ts | 10 +++++++--- core/flyout_button.ts | 19 +++++++++++++------ core/flyout_item.ts | 5 +++++ core/utils/aria.ts | 3 +++ core/workspace_svg.ts | 4 ++-- 9 files changed, 65 insertions(+), 30 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index f645cb6c0..d8ac52e29 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -239,10 +239,16 @@ export class BlockSvg aria.setState( this.getFocusableElement(), aria.State.LABEL, - this.computeAriaLabel(), + !this.isInFlyout + ? this.computeAriaLabel() + : this.computeAriaLabelForFlyoutBlock(), ); } + private computeAriaLabelForFlyoutBlock(): string { + return `${this.computeAriaLabel(true)}, block`; + } + computeAriaLabel( verbose: boolean = false, minimal: boolean = false, @@ -305,7 +311,7 @@ export class BlockSvg private computeAriaRole() { if (this.workspace.isFlyout) { - aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM); + aria.setRole(this.pathObject.svgPath, aria.Role.LISTITEM); } else { const roleDescription = this.getAriaRoleDescription(); aria.setState( diff --git a/core/field_checkbox.ts b/core/field_checkbox.ts index aecead2e8..a0a380453 100644 --- a/core/field_checkbox.ts +++ b/core/field_checkbox.ts @@ -122,13 +122,18 @@ export class FieldCheckbox extends Field { private recomputeAria() { const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.CHECKBOX); - aria.setState( - element, - aria.State.LABEL, - this.getAriaTypeName() ?? 'Checkbox', - ); - aria.setState(element, aria.State.CHECKED, !!this.value_); + const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false; + if (!isInFlyout) { + aria.setRole(element, aria.Role.CHECKBOX); + aria.setState( + element, + aria.State.LABEL, + this.getAriaTypeName() ?? 'Checkbox', + ); + aria.setState(element, aria.State.CHECKED, !!this.value_); + } else { + aria.setState(element, aria.State.HIDDEN, true); + } } override render_() { diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index de0955b93..be3381082 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -208,17 +208,21 @@ export class FieldDropdown extends Field { protected recomputeAria() { if (!this.fieldGroup_) return; // There's no element to set currently. + const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false; const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.COMBOBOX); - aria.setState(element, aria.State.HASPOPUP, aria.Role.LISTBOX); - aria.setState(element, aria.State.EXPANDED, !!this.menu_); - if (this.menu_) { - aria.setState(element, aria.State.CONTROLS, this.menu_.id); + if (!isInFlyout) { + aria.setRole(element, aria.Role.COMBOBOX); + aria.setState(element, aria.State.HASPOPUP, aria.Role.LISTBOX); + aria.setState(element, aria.State.EXPANDED, !!this.menu_); + if (this.menu_) { + aria.setState(element, aria.State.CONTROLS, this.menu_.id); + } else { + aria.clearState(element, aria.State.CONTROLS); + } + aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true)); } else { - aria.clearState(element, aria.State.CONTROLS); + aria.setState(element, aria.State.HIDDEN, true); } - - aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true)); } /** diff --git a/core/field_image.ts b/core/field_image.ts index b7aaf5e06..bfa19816e 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -159,13 +159,14 @@ export class FieldImage extends Field { dom.addClass(this.fieldGroup_, 'blocklyImageField'); } + const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false; const element = this.getFocusableElement(); - if (this.isClickable()) { + if (!isInFlyout && this.isClickable()) { this.imageElement.style.cursor = 'pointer'; aria.setRole(element, aria.Role.BUTTON); aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true)); } else { - // The field isn't navigable unless it's clickable. + // The field isn't navigable unless it's clickable and outside the flyout. aria.setRole(element, aria.Role.PRESENTATION); } } diff --git a/core/field_input.ts b/core/field_input.ts index 7132d9ab1..175e80ff5 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -178,8 +178,6 @@ export abstract class FieldInput extends Field< dom.addClass(this.fieldGroup_, 'blocklyInputField'); } - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.BUTTON); this.recomputeAriaLabel(); } @@ -189,7 +187,13 @@ export abstract class FieldInput extends Field< protected recomputeAriaLabel() { if (!this.fieldGroup_) return; const element = this.getFocusableElement(); - aria.setState(element, aria.State.LABEL, super.computeAriaLabel()); + const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false; + if (!isInFlyout) { + aria.setRole(element, aria.Role.BUTTON); + aria.setState(element, aria.State.LABEL, super.computeAriaLabel()); + } else { + aria.setState(element, aria.State.HIDDEN, true); + } } override isFullBlockField(): boolean { diff --git a/core/flyout_button.ts b/core/flyout_button.ts index 74de275a4..63e3f6a7c 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -132,12 +132,13 @@ export class FlyoutButton this.svgContainerGroup, ); - aria.setRole(this.svgContainerGroup, aria.Role.TREEITEM); if (this.isFlyoutLabel) { + aria.setRole(this.svgContainerGroup, aria.Role.LISTITEM); aria.setRole(this.svgContentGroup, aria.Role.PRESENTATION); this.svgFocusableGroup = this.svgContainerGroup; } else { - aria.setRole(this.svgContentGroup, aria.Role.BUTTON); + aria.setRole(this.svgContainerGroup, aria.Role.PRESENTATION); + aria.setRole(this.svgContentGroup, aria.Role.LISTITEM); this.svgFocusableGroup = this.svgContentGroup; } this.svgFocusableGroup.id = this.id; @@ -183,9 +184,7 @@ export class FlyoutButton }, this.svgContentGroup, ); - if (!this.isFlyoutLabel) { - aria.setRole(svgText, aria.Role.PRESENTATION); - } + aria.setRole(svgText, aria.Role.PRESENTATION); let text = parsing.replaceMessageReferences(this.text); if (this.workspace.RTL) { // Force text to be RTL by adding an RLM. @@ -198,7 +197,15 @@ export class FlyoutButton .getThemeManager() .subscribe(this.svgText, 'flyoutForegroundColour', 'fill'); } - aria.setState(this.svgFocusableGroup, aria.State.LABEL, text); + if (this.isFlyoutLabel) { + aria.setState(this.svgFocusableGroup, aria.State.LABEL, text); + } else { + aria.setState( + this.svgFocusableGroup, + aria.State.LABEL, + `${text}, button`, + ); + } const fontSize = style.getComputedStyle(svgText, 'fontSize'); const fontWeight = style.getComputedStyle(svgText, 'fontWeight'); diff --git a/core/flyout_item.ts b/core/flyout_item.ts index 26be0ed12..1d6c4f317 100644 --- a/core/flyout_item.ts +++ b/core/flyout_item.ts @@ -8,6 +8,11 @@ export class FlyoutItem { /** * Creates a new FlyoutItem. * + * Note that it's the responsibility of implementations to ensure that element + * is given the ARIA role LISTITEM and respects its expected constraints + * (which includes ensuring that no interactive elements are children of the + * item element--interactive elements themselves should be the LISTITEM). + * * @param element The element that will be displayed in the flyout. * @param type The type of element. Should correspond to the type of the * flyout inflater that created this object. diff --git a/core/utils/aria.ts b/core/utils/aria.ts index 64d1bf143..8535f8983 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -28,6 +28,7 @@ export enum Role { // ARIA role for menu item elements. MENUITEM = 'menuitem', + // ARIA role for option items that are children of combobox, listbox, menu, // radiogroup, or tree elements. OPTION = 'option', @@ -55,6 +56,8 @@ export enum Role { SPINBUTTON = 'spinbutton', REGION = 'region', GENERIC = 'generic', + LIST = 'list', + LISTITEM = 'listitem', } /** diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 1f16f24c6..3e0c8324e 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -804,8 +804,8 @@ export class WorkspaceSvg this.svgBubbleCanvas_ = this.layerManager.getBubbleLayer(); if (this.isFlyout) { - // Use the block canvas as the primary tree parent for flyout blocks. - aria.setRole(this.svgBlockCanvas_, aria.Role.TREE); + // Use the block canvas as the primary list for nesting. + aria.setRole(this.svgBlockCanvas_, aria.Role.LIST); aria.setState(this.svgBlockCanvas_, aria.State.LABEL, ariaLabel); } else { browserEvents.conditionalBind(