From d0ad9343f0c9939d1b1db61b13c7ac36fe3893cc Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 6 Aug 2025 15:28:45 -0700 Subject: [PATCH 01/51] feat: Add initial support for screen readers (experimental) (#9280) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes part of #8207 Fixes part of #3370 ### Proposed Changes This introduces initial broad ARIA integration in order to enable at least basic screen reader support when using keyboard navigation. Largely this involves introducing ARIA roles and labels in a bunch of places, sometimes done in a way to override normal built-in behaviors of the accessibility node tree in order to get a richer first-class output for Blockly (such as for blocks and workspaces). ### Reason for Changes ARIA is the fundamental basis for configuring how focusable nodes in Blockly are represented to the user when using a screen reader. As such, all focusable nodes requires labels and roles in order to correctly communicate their contexts. The specific approach taken in this PR is to simply add labels and roles to all nodes where obvious with some extra work done for `WorkspaceSvg` and `BlockSvg` in order to represent blocks as a tree (since that seems to be the best fitting ARIA role per those available: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles). The custom work specifically for blocks includes: - Overriding the role description to be 'block' rather than 'tree item' (which is the default). - Overriding the position, level, and number of sibling counts since those are normally determined based on the DOM tree and blocks are not laid out in the tree the same way they are visually or logically (so these computations were incorrect). This is also the reason for a bunch of extra computation logic being introduced. One note on some of the labels being nonsensical (e.g. 'DoNotOverride?'): this was done intentionally to try and ensure _all_ focusable nodes (that can be focused) have labels, even when the specifics of what that label should be aren't yet clear. More components had these temporary labels until testing revealed how exactly they would behave from a screen reader perspective (at which point their roles and labels were updated as needed). The temporary labels act as an indicator when navigating through the UI, and some of the nodes can't easily be reached (for reasons) and thus may never actually need a label. More work is needed in understanding both what components need labels and what those labels should be, but that will be done beyond this PR. ### Test Coverage No tests are added to this as it's experimental and not a final implementation. The keyboard navigation tests are failing due to a visibility expansion of `connectionCandidate` in `BlockDragStrategy`. There's no way to avoid this breakage, unfortunately. Instead, this PR will be merged and then https://github.com/google/blockly-keyboard-experimentation/pull/684 will be finalized and merged to fix it. There's some additional work that will happen both in that branch and in a later PR in core Blockly to integrate the two experimentation branches as part of #9283 so that CI passes correctly for both branches. ### Documentation No documentation is needed at this time. ### Additional Information This work is experimental and is meant to serve two purposes: - Provide a foundation for testing and iterating the core screen reader experience in Blockly. - Provide a reference point for designing a long-term solution that accounts for all requirements collected during user testing. This code should never be merged into `develop` as it stands. Instead, it will be redesigned with maintainability, testing, and correctness in mind at a future date (see https://github.com/google/blockly-keyboard-experimentation/discussions/673). --- core/block_svg.ts | 129 +++++++++++++++++++ core/bubbles/bubble.ts | 3 + core/comments/collapse_comment_bar_button.ts | 6 + core/comments/comment_bar_button.ts | 2 + core/comments/comment_editor.ts | 3 + core/comments/comment_view.ts | 4 + core/comments/delete_comment_bar_button.ts | 6 + core/css.ts | 8 ++ core/dragging/block_drag_strategy.ts | 2 +- core/field.ts | 2 + core/field_checkbox.ts | 9 ++ core/field_dropdown.ts | 8 ++ core/field_image.ts | 9 ++ core/field_input.ts | 8 ++ core/field_label.ts | 14 ++ core/flyout_button.ts | 4 + core/icons/comment_icon.ts | 15 +++ core/icons/icon.ts | 4 + core/icons/mutator_icon.ts | 13 ++ core/icons/warning_icon.ts | 13 ++ core/inject.ts | 7 + core/rendered_connection.ts | 3 + core/toolbox/category.ts | 2 + core/toolbox/collapsible_category.ts | 17 ++- core/toolbox/separator.ts | 6 + core/toolbox/toolbox.ts | 29 ++++- core/utils/aria.ts | 117 ++++++++++++----- core/utils/dom.ts | 6 +- core/workspace_svg.ts | 66 +++++++++- 29 files changed, 472 insertions(+), 43 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index c6065282a5c..08fb329e787 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -56,6 +56,7 @@ import * as blocks from './serialization/blocks.js'; import type {BlockStyle} from './theme.js'; import * as Tooltip from './tooltip.js'; import {idGenerator} from './utils.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import {Rect} from './utils/rect.js'; @@ -168,6 +169,8 @@ export class BlockSvg /** Whether this block is currently being dragged. */ private dragging = false; + public currentConnectionCandidate: RenderedConnection | null = null; + /** * The location of the top left of this block (in workspace coordinates) * relative to either its parent block, or the workspace origin if it has no @@ -215,7 +218,69 @@ export class BlockSvg // The page-wide unique ID of this Block used for focusing. svgPath.id = idGenerator.getNextUniqueId(); + aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); + aria.setRole(svgPath, aria.Role.TREEITEM); + svgPath.tabIndex = -1; + this.currentConnectionCandidate = null; + this.doInit_(); + + // Note: This must be done after initialization of the block's fields. + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + aria.setState( + this.getFocusableElement(), + aria.State.LABEL, + this.computeAriaLabel(), + ); + } + + private computeAriaLabel(): string { + // Guess the block's aria label based on its field labels. + if (this.isShadow()) { + // TODO: Shadows may have more than one field. + // Shadow blocks are best represented directly by their field since they + // effectively operate like a field does for keyboard navigation purposes. + const field = Array.from(this.getFields())[0]; + return ( + aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? + 'Unknown?' + ); + } + + const fieldLabels = []; + for (const field of this.getFields()) { + if (field instanceof FieldLabel) { + fieldLabels.push(field.getText()); + } + } + return fieldLabels.join(' '); + } + + collectSiblingBlocks(surroundParent: BlockSvg | null): BlockSvg[] { + // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The + // returned list needs to be relatively stable for consistency block indexes + // read out to users via screen readers. + if (surroundParent) { + // Start from the first sibling and iterate in navigation order. + const firstSibling: BlockSvg = surroundParent.getChildren(false)[0]; + const siblings: BlockSvg[] = [firstSibling]; + let nextSibling: BlockSvg | null = firstSibling; + while ((nextSibling = nextSibling.getNextBlock())) { + siblings.push(nextSibling); + } + return siblings; + } else { + // For top-level blocks, simply return those from the workspace. + return this.workspace.getTopBlocks(false); + } + } + + computeLevelInWorkspace(): number { + const surroundParent = this.getSurroundParent(); + return surroundParent ? surroundParent.computeLevelInWorkspace() + 1 : 0; } /** @@ -266,12 +331,14 @@ export class BlockSvg select() { this.addSelect(); common.fireSelectedEvent(this); + aria.setState(this.getFocusableElement(), aria.State.SELECTED, true); } /** Unselects this block. Unhighlights the block visually. */ unselect() { this.removeSelect(); common.fireSelectedEvent(null); + aria.setState(this.getFocusableElement(), aria.State.SELECTED, false); } /** @@ -342,6 +409,8 @@ export class BlockSvg } this.applyColour(); + + this.workspace.recomputeAriaTree(); } /** @@ -1776,21 +1845,32 @@ export class BlockSvg /** Starts a drag on the block. */ startDrag(e?: PointerEvent): void { this.dragStrategy.startDrag(e); + const dragStrategy = this.dragStrategy as BlockDragStrategy; + const candidate = dragStrategy.connectionCandidate?.neighbour ?? null; + this.currentConnectionCandidate = candidate; + this.announceDynamicAriaState(true, false); } /** Drags the block to the given location. */ drag(newLoc: Coordinate, e?: PointerEvent): void { this.dragStrategy.drag(newLoc, e); + const dragStrategy = this.dragStrategy as BlockDragStrategy; + const candidate = dragStrategy.connectionCandidate?.neighbour ?? null; + this.currentConnectionCandidate = candidate; + this.announceDynamicAriaState(true, false, newLoc); } /** Ends the drag on the block. */ endDrag(e?: PointerEvent): void { this.dragStrategy.endDrag(e); + this.currentConnectionCandidate = null; + this.announceDynamicAriaState(false, false); } /** Moves the block back to where it was at the start of a drag. */ revertDrag(): void { this.dragStrategy.revertDrag(); + this.announceDynamicAriaState(false, true); } /** @@ -1855,4 +1935,53 @@ export class BlockSvg canBeFocused(): boolean { return true; } + + /** + * Announces the current dynamic state of the specified block, if any. + * + * An example of dynamic state is whether the block is currently being moved, + * and in what way. These states aren't represented through ARIA directly, so + * they need to be determined and announced using an ARIA live region + * (see aria.announceDynamicAriaState). + * + * @param isMoving Whether the specified block is currently being moved. + * @param isCanceled Whether the previous movement operation has been canceled. + * @param newLoc The new location the block is moving to (if unconstrained). + */ + private announceDynamicAriaState( + isMoving: boolean, + isCanceled: boolean, + newLoc?: Coordinate, + ) { + if (isCanceled) { + aria.announceDynamicAriaState('Canceled movement'); + return; + } + if (!isMoving) return; + if (this.currentConnectionCandidate) { + // TODO: Figure out general detachment. + // TODO: Figure out how to deal with output connections. + const surroundParent = this.currentConnectionCandidate.sourceBlock_; + const announcementContext = []; + announcementContext.push('Moving'); // TODO: Specialize for inserting? + // NB: Old code here doesn't seem to handle parents correctly. + if (this.currentConnectionCandidate.type === ConnectionType.INPUT_VALUE) { + announcementContext.push('to', 'input'); + } else { + announcementContext.push('to', 'child'); + } + if (surroundParent) { + announcementContext.push('of', surroundParent.computeAriaLabel()); + } + + // If the block is currently being moved, announce the new block label so that the user understands where it is now. + // TODO: Figure out how much recomputeAriaTreeItemDetailsRecursively needs to anticipate position if it won't be reannounced, and how much of that context should be included in the liveannouncement. + aria.announceDynamicAriaState(announcementContext.join(' ')); + } else if (newLoc) { + // The block is being freely dragged. + aria.announceDynamicAriaState( + `Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`, + ); + } + } } diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index c42e602544e..4ff50c9d750 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -15,6 +15,7 @@ import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import {ISelectable} from '../interfaces/i_selectable.js'; import {ContainerRegion} from '../metrics_manager.js'; import {Scrollbar} from '../scrollbar.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as idGenerator from '../utils/idgenerator.js'; @@ -142,6 +143,8 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { this.focusableElement = overriddenFocusableElement ?? this.svgRoot; this.focusableElement.setAttribute('id', this.id); + aria.setRole(this.focusableElement, aria.Role.GROUP); + aria.setState(this.focusableElement, aria.State.LABEL, 'Bubble'); browserEvents.conditionalBind( this.background, diff --git a/core/comments/collapse_comment_bar_button.ts b/core/comments/collapse_comment_bar_button.ts index b0738d70705..524f6e4c922 100644 --- a/core/comments/collapse_comment_bar_button.ts +++ b/core/comments/collapse_comment_bar_button.ts @@ -6,6 +6,7 @@ import * as browserEvents from '../browser_events.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Svg} from '../utils/svg.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -69,6 +70,11 @@ export class CollapseCommentBarButton extends CommentBarButton { browserEvents.unbind(this.bindId); } + override initAria(): void { + aria.setRole(this.icon, aria.Role.BUTTON); + aria.setState(this.icon, aria.State.LABEL, 'DoNotDefine?'); + } + /** * Adjusts the positioning of this button within its container. */ diff --git a/core/comments/comment_bar_button.ts b/core/comments/comment_bar_button.ts index d78a7fd86a1..5cbebe74ae8 100644 --- a/core/comments/comment_bar_button.ts +++ b/core/comments/comment_bar_button.ts @@ -52,6 +52,8 @@ export abstract class CommentBarButton implements IFocusableNode { return comment; } + abstract initAria(): void; + /** Adjusts the position of this button within its parent container. */ abstract reposition(): void; diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts index ac1559c4b3d..9ba0d04f9eb 100644 --- a/core/comments/comment_editor.ts +++ b/core/comments/comment_editor.ts @@ -9,6 +9,7 @@ import {getFocusManager} from '../focus_manager.js'; import {IFocusableNode} from '../interfaces/i_focusable_node.js'; import {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; @@ -54,6 +55,8 @@ export class CommentEditor implements IFocusableNode { ) as HTMLTextAreaElement; this.textArea.setAttribute('tabindex', '-1'); this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR'); + aria.setRole(this.textArea, aria.Role.TEXTBOX); + aria.setState(this.textArea, aria.State.LABEL, 'DoNotDefine?'); dom.addClass(this.textArea, 'blocklyCommentText'); dom.addClass(this.textArea, 'blocklyTextarea'); dom.addClass(this.textArea, 'blocklyText'); diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 936d746508f..782c79e49e3 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -10,6 +10,7 @@ import type {IFocusableNode} from '../interfaces/i_focusable_node'; import {IRenderedElement} from '../interfaces/i_rendered_element.js'; import * as layers from '../layers.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as drag from '../utils/drag.js'; @@ -108,6 +109,9 @@ export class CommentView implements IRenderedElement { 'class': 'blocklyComment blocklyEditable blocklyDraggable', }); + aria.setRole(this.svgRoot, aria.Role.TEXTBOX); + aria.setState(this.svgRoot, aria.State.LABEL, 'DoNotOverride?'); + this.highlightRect = this.createHighlightRect(this.svgRoot); ({ diff --git a/core/comments/delete_comment_bar_button.ts b/core/comments/delete_comment_bar_button.ts index 0b7dcd0ea27..508646c566b 100644 --- a/core/comments/delete_comment_bar_button.ts +++ b/core/comments/delete_comment_bar_button.ts @@ -7,6 +7,7 @@ import * as browserEvents from '../browser_events.js'; import {getFocusManager} from '../focus_manager.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Svg} from '../utils/svg.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -69,6 +70,11 @@ export class DeleteCommentBarButton extends CommentBarButton { browserEvents.unbind(this.bindId); } + override initAria(): void { + aria.setRole(this.icon, aria.Role.BUTTON); + aria.setState(this.icon, aria.State.LABEL, 'DoNotDefine?'); + } + /** * Adjusts the positioning of this button within its container. */ diff --git a/core/css.ts b/core/css.ts index 30ee47fc58a..15b6fe1b6ff 100644 --- a/core/css.ts +++ b/core/css.ts @@ -507,4 +507,12 @@ input[type=number] { ) { outline: none; } + +#blocklyAriaAnnounce { + position: absolute; + left: -9999px; + width: 1px; + height: px; + overflow: hidden; +} `; diff --git a/core/dragging/block_drag_strategy.ts b/core/dragging/block_drag_strategy.ts index 76020f90b5b..620308155e3 100644 --- a/core/dragging/block_drag_strategy.ts +++ b/core/dragging/block_drag_strategy.ts @@ -50,7 +50,7 @@ export class BlockDragStrategy implements IDragStrategy { private startLoc: Coordinate | null = null; - private connectionCandidate: ConnectionCandidate | null = null; + public connectionCandidate: ConnectionCandidate | null = null; private connectionPreviewer: IConnectionPreviewer | null = null; diff --git a/core/field.ts b/core/field.ts index fdcb2d693b9..28a0e3977ad 100644 --- a/core/field.ts +++ b/core/field.ts @@ -31,6 +31,7 @@ import {ISerializable} from './interfaces/i_serializable.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'; @@ -403,6 +404,7 @@ export abstract class Field } this.textContent_ = document.createTextNode(''); this.textElement_.appendChild(this.textContent_); + aria.setState(this.textElement_, aria.State.HIDDEN, true); } /** diff --git a/core/field_checkbox.ts b/core/field_checkbox.ts index 55ed42cbf4b..f7ab38ead56 100644 --- a/core/field_checkbox.ts +++ b/core/field_checkbox.ts @@ -16,6 +16,7 @@ import './events/events_block_change.js'; import {Field, FieldConfig, FieldValidator} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; type BoolString = 'TRUE' | 'FALSE'; @@ -111,6 +112,14 @@ export class FieldCheckbox extends Field { const textElement = this.getTextElement(); dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField'); textElement.style.display = this.value_ ? 'block' : 'none'; + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.CHECKBOX); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Checkbox ${this.name}` : 'Checkbox', + ); } override render_() { diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 8b01ccddab1..f3badfd6e83 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -196,6 +196,14 @@ export class FieldDropdown extends Field { dom.addClass(this.fieldGroup_, 'blocklyField'); dom.addClass(this.fieldGroup_, 'blocklyDropdownField'); } + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.LISTBOX); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Item ${this.name}` : 'Item', + ); } /** diff --git a/core/field_image.ts b/core/field_image.ts index 01133c20340..e6ac13e0810 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -13,6 +13,7 @@ import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; @@ -157,6 +158,14 @@ export class FieldImage extends Field { if (this.clickHandler) { this.imageElement.style.cursor = 'pointer'; } + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.IMAGE); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Image ${this.name}` : 'Image', + ); } override updateSize_() {} diff --git a/core/field_input.ts b/core/field_input.ts index b685309183a..539fc8a6bea 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -172,6 +172,14 @@ export abstract class FieldInput extends Field< if (this.fieldGroup_) { dom.addClass(this.fieldGroup_, 'blocklyInputField'); } + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.TEXTBOX); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Text ${this.name}` : 'Text', + ); } override isFullBlockField(): boolean { diff --git a/core/field_label.ts b/core/field_label.ts index 236154cc7b1..901c21bd000 100644 --- a/core/field_label.ts +++ b/core/field_label.ts @@ -14,6 +14,7 @@ import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -77,6 +78,12 @@ export class FieldLabel extends Field { if (this.fieldGroup_) { dom.addClass(this.fieldGroup_, 'blocklyLabelField'); } + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + aria.setState(this.getFocusableElement(), aria.State.LABEL, this.getText()); } /** @@ -111,6 +118,13 @@ export class FieldLabel extends Field { this.class = cssClass; } + override setValue(newValue: any, fireChangeEvent?: boolean): void { + super.setValue(newValue, fireChangeEvent); + if (this.fieldGroup_) { + this.recomputeAriaLabel(); + } + } + /** * Construct a FieldLabel from a JSON arg object, * dereferencing any string table references. diff --git a/core/flyout_button.ts b/core/flyout_button.ts index c9afb8b0159..0e83706709b 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -18,6 +18,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {IRenderedElement} from './interfaces/i_rendered_element.js'; import {idGenerator} from './utils.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -117,6 +118,9 @@ export class FlyoutButton this.workspace.getCanvas(), ); + aria.setRole(this.svgGroup, aria.Role.BUTTON); + aria.setState(this.svgGroup, aria.State.LABEL, 'Button'); + let shadow; if (!this.isFlyoutLabel) { // Shadow rectangle (light source does not mirror in RTL). diff --git a/core/icons/comment_icon.ts b/core/icons/comment_icon.ts index 8f5a82c0d15..1b2e47149e4 100644 --- a/core/icons/comment_icon.ts +++ b/core/icons/comment_icon.ts @@ -15,6 +15,7 @@ import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import type {ISerializable} from '../interfaces/i_serializable.js'; import * as renderManagement from '../render_management.js'; import {Coordinate} from '../utils.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; import {Size} from '../utils/size.js'; @@ -112,6 +113,18 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { this.svgRoot, ); dom.addClass(this.svgRoot!, 'blocklyCommentIcon'); + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + if (this.svgRoot) { + aria.setState( + this.svgRoot, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Comment' : 'Open Comment', + ); + } } override dispose() { @@ -336,6 +349,8 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { 'comment', ), ); + + this.recomputeAriaLabel(); } /** See IHasBubble.getBubble. */ diff --git a/core/icons/icon.ts b/core/icons/icon.ts index 8f8ff70fc32..eb5b56a8080 100644 --- a/core/icons/icon.ts +++ b/core/icons/icon.ts @@ -11,6 +11,7 @@ import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import {hasBubble} from '../interfaces/i_has_bubble.js'; import type {IIcon} from '../interfaces/i_icon.js'; import * as tooltip from '../tooltip.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as idGenerator from '../utils/idgenerator.js'; @@ -71,6 +72,9 @@ export abstract class Icon implements IIcon { ); (this.svgRoot as any).tooltip = this; tooltip.bindMouseEvents(this.svgRoot); + + aria.setRole(this.svgRoot, aria.Role.FIGURE); + aria.setState(this.svgRoot, aria.State.LABEL, 'Icon'); } dispose(): void { diff --git a/core/icons/mutator_icon.ts b/core/icons/mutator_icon.ts index 9055a91ea8f..af32f55df1c 100644 --- a/core/icons/mutator_icon.ts +++ b/core/icons/mutator_icon.ts @@ -16,6 +16,7 @@ import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import * as renderManagement from '../render_management.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; @@ -119,6 +120,16 @@ export class MutatorIcon extends Icon implements IHasBubble { this.svgRoot, ); dom.addClass(this.svgRoot!, 'blocklyMutatorIcon'); + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + aria.setState( + this.svgRoot!, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator', + ); } override dispose(): void { @@ -201,6 +212,8 @@ export class MutatorIcon extends Icon implements IHasBubble { 'mutator', ), ); + + this.recomputeAriaLabel(); } /** See IHasBubble.getBubble. */ diff --git a/core/icons/warning_icon.ts b/core/icons/warning_icon.ts index f24a6a56190..5085769eec9 100644 --- a/core/icons/warning_icon.ts +++ b/core/icons/warning_icon.ts @@ -14,6 +14,7 @@ import type {IBubble} from '../interfaces/i_bubble.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import * as renderManagement from '../render_management.js'; import {Size} from '../utils.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; @@ -92,6 +93,16 @@ export class WarningIcon extends Icon implements IHasBubble { this.svgRoot, ); dom.addClass(this.svgRoot!, 'blocklyWarningIcon'); + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + aria.setState( + this.svgRoot!, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Warning' : 'Open Warning', + ); } override dispose() { @@ -196,6 +207,8 @@ export class WarningIcon extends Icon implements IHasBubble { 'warning', ), ); + + this.recomputeAriaLabel(); } /** See IHasBubble.getBubble. */ diff --git a/core/inject.ts b/core/inject.ts index 4217c515119..3488f669d61 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -17,6 +17,7 @@ import {Options} from './options.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import * as Tooltip from './tooltip.js'; import * as Touch from './touch.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import {Svg} from './utils/svg.js'; import * as WidgetDiv from './widgetdiv.js'; @@ -78,6 +79,12 @@ export function inject( common.globalShortcutHandler, ); + // See: https://stackoverflow.com/a/48590836 for a reference. + const ariaAnnouncementSpan = document.createElement('span'); + ariaAnnouncementSpan.id = 'blocklyAriaAnnounce'; + aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'polite'); + subContainer.appendChild(ariaAnnouncementSpan); + return workspace; } diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index 84905eeccc2..f1cab26da33 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -25,6 +25,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {hasBubble} from './interfaces/i_has_bubble.js'; import * as internalConstants from './internal_constants.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as svgMath from './utils/svg_math.js'; import {WorkspaceSvg} from './workspace_svg.js'; @@ -332,6 +333,8 @@ export class RenderedConnection const highlightSvg = this.findHighlightSvg(); if (highlightSvg) { highlightSvg.style.display = ''; + aria.setRole(highlightSvg, aria.Role.FIGURE); + aria.setState(highlightSvg, aria.State.LABEL, 'Open connection'); } } diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index 7b0db7b3fcd..d86a41cb613 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -30,6 +30,7 @@ import type { StaticCategoryInfo, } from '../utils/toolbox.js'; import * as toolbox from '../utils/toolbox.js'; +import {Toolbox} from './toolbox.js'; import {ToolboxItem} from './toolbox_item.js'; /** @@ -192,6 +193,7 @@ export class ToolboxCategory aria.setRole(this.htmlDiv_, aria.Role.TREEITEM); aria.setState(this.htmlDiv_, aria.State.SELECTED, false); aria.setState(this.htmlDiv_, aria.State.LEVEL, this.level_ + 1); + (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); this.rowDiv_ = this.createRowContainer_(); this.rowDiv_.style.pointerEvents = 'auto'; diff --git a/core/toolbox/collapsible_category.ts b/core/toolbox/collapsible_category.ts index 5048ff1269d..342230fdd1b 100644 --- a/core/toolbox/collapsible_category.ts +++ b/core/toolbox/collapsible_category.ts @@ -20,6 +20,7 @@ import * as dom from '../utils/dom.js'; import * as toolbox from '../utils/toolbox.js'; import {ToolboxCategory} from './category.js'; import {ToolboxSeparator} from './separator.js'; +import {Toolbox} from './toolbox.js'; /** * Class for a category in a toolbox that can be collapsed. @@ -132,11 +133,25 @@ export class CollapsibleToolboxCategory const subCategories = this.getChildToolboxItems(); this.subcategoriesDiv_ = this.createSubCategoriesDom_(subCategories); - aria.setRole(this.subcategoriesDiv_, aria.Role.GROUP); this.htmlDiv_!.appendChild(this.subcategoriesDiv_); this.closeIcon_(this.iconDom_); aria.setState(this.htmlDiv_ as HTMLDivElement, aria.State.EXPANDED, false); + aria.setRole(this.htmlDiv_!, aria.Role.GROUP); + + // Ensure this group has properly set children. + const selectableChildren = + this.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildIds = selectableChildren.map( + (selectable) => selectable.getFocusableElement().id, + ); + aria.setState( + this.htmlDiv_!, + aria.State.OWNS, + [...new Set(focusableChildIds)].join(' '), + ); + (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); + return this.htmlDiv_!; } diff --git a/core/toolbox/separator.ts b/core/toolbox/separator.ts index cd5ed245a04..9c2b5e6e2d2 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -14,8 +14,10 @@ import * as Css from '../css.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import * as registry from '../registry.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import type * as toolbox from '../utils/toolbox.js'; +import {Toolbox} from './toolbox.js'; import {ToolboxItem} from './toolbox_item.js'; /** @@ -63,6 +65,10 @@ export class ToolboxSeparator extends ToolboxItem { dom.addClass(container, className); } this.htmlDiv = container; + + aria.setRole(this.htmlDiv, aria.Role.SEPARATOR); + (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); + return container; } diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index f34034d3399..3b87f1ef2c2 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -153,6 +153,8 @@ export class Toolbox this.setVisible(true); this.flyout.init(workspace); + aria.setRole(this.HtmlDiv, aria.Role.TREE); + this.render(this.toolboxDef_); const themeManager = workspace.getThemeManager(); themeManager.subscribe( @@ -186,7 +188,6 @@ export class Toolbox container.id = idGenerator.getNextUniqueId(); this.contentsDiv_ = this.createContentsContainer_(); - aria.setRole(this.contentsDiv_, aria.Role.TREE); container.appendChild(this.contentsDiv_); svg.parentNode!.insertBefore(container, svg); @@ -1150,6 +1151,32 @@ export class Toolbox this.autoHide(false); } } + + /** + * Recomputes ARIA tree ownership relationships for all of this toolbox's + * categories and items. + * + * This should only be done when the toolbox's contents have changed. + */ + recomputeAriaOwners() { + const focusable = this.getFocusableElement(); + const selectableChildren = + this.getToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildElems = selectableChildren.map((selectable) => + selectable.getFocusableElement(), + ); + const focusableChildIds = focusableChildElems.map((elem) => elem.id); + aria.setState( + focusable, + aria.State.OWNS, + [...new Set(focusableChildIds)].join(' '), + ); + // Ensure children have the correct position set. + // TODO: Fix collapsible subcategories. Their groups aren't set up correctly yet, and they aren't getting a correct accounting in top-level toolbox tree. + focusableChildElems.forEach((elem, index) => + aria.setState(elem, aria.State.POSINSET, index + 1), + ); + } } /** CSS for Toolbox. See css.js for use. */ diff --git a/core/utils/aria.ts b/core/utils/aria.ts index d997b8d0af0..aa9ec3f29d6 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -17,11 +17,6 @@ const ROLE_ATTRIBUTE = 'role'; * Copied from Closure's goog.a11y.aria.Role */ export enum Role { - // ARIA role for an interactive control of tabular data. - GRID = 'grid', - - // ARIA role for a cell in a grid. - GRIDCELL = 'gridcell', // ARIA role for a group of related elements like tree item siblings. GROUP = 'group', @@ -33,16 +28,12 @@ export enum Role { // ARIA role for menu item elements. MENUITEM = 'menuitem', - // ARIA role for a checkbox box element inside a menu. - MENUITEMCHECKBOX = 'menuitemcheckbox', // ARIA role for option items that are children of combobox, listbox, menu, // radiogroup, or tree elements. OPTION = 'option', // ARIA role for ignorable cosmetic elements with no semantic significance. PRESENTATION = 'presentation', - // ARIA role for a row of cells in a grid. - ROW = 'row', // ARIA role for a tree. TREE = 'tree', @@ -54,6 +45,12 @@ export enum Role { // ARIA role for a live region providing information. STATUS = 'status', + + IMAGE = 'image', + FIGURE = 'figure', + BUTTON = 'button', + CHECKBOX = 'checkbox', + TEXTBOX = 'textbox', } /** @@ -64,10 +61,6 @@ export enum State { // ARIA property for setting the currently active descendant of an element, // for example the selected item in a list box. Value: ID of an element. ACTIVEDESCENDANT = 'activedescendant', - // ARIA property defines the total number of columns in a table, grid, or - // treegrid. - // Value: integer. - COLCOUNT = 'colcount', // ARIA state for a disabled item. Value: one of {true, false}. DISABLED = 'disabled', @@ -89,19 +82,11 @@ export enum State { // ARIA property for setting the level of an element in the hierarchy. // Value: integer. LEVEL = 'level', - // ARIA property indicating if the element is horizontal or vertical. - // Value: one of {'vertical', 'horizontal'}. - ORIENTATION = 'orientation', // ARIA property that defines an element's number of position in a list. // Value: integer. POSINSET = 'posinset', - // ARIA property defines the total number of rows in a table, grid, or - // treegrid. - // Value: integer. - ROWCOUNT = 'rowcount', - // ARIA state for setting the currently selected item in the list. // Value: one of {true, false, undefined}. SELECTED = 'selected', @@ -121,29 +106,52 @@ export enum State { // ARIA property for removing elements from the accessibility tree. // Value: one of {true, false, undefined}. HIDDEN = 'hidden', + + ROLEDESCRIPTION = 'roledescription', + OWNS = 'owns', } /** - * Sets the role of an element. + * Updates the specific role for the specified element. * - * Similar to Closure's goog.a11y.aria + * @param element The element whose ARIA role should be changed. + * @param roleName The new role for the specified element, or null if its role + * should be cleared. + */ +export function setRole(element: Element, roleName: Role | null) { + if (roleName) { + element.setAttribute(ROLE_ATTRIBUTE, roleName); + } else element.removeAttribute(ROLE_ATTRIBUTE); +} + +/** + * Returns the ARIA role of the specified element, or null if it either doesn't + * have a designated role or if that role is unknown. * - * @param element DOM node to set role of. - * @param roleName Role name. + * @param element The element from which to retrieve its ARIA role. + * @returns The ARIA role of the element, or null if undefined or unknown. */ -export function setRole(element: Element, roleName: Role) { - element.setAttribute(ROLE_ATTRIBUTE, roleName); +export function getRole(element: Element): Role | null { + // This is an unsafe cast which is why it needs to be checked to ensure that + // it references a valid role. + const currentRoleName = element.getAttribute(ROLE_ATTRIBUTE) as Role; + if (Object.values(Role).includes(currentRoleName)) { + return currentRoleName; + } + return null; } /** - * Sets the state or property of an element. - * Copied from Closure's goog.a11y.aria + * Sets the specified ARIA state by its name and value for the specified + * element. * - * @param element DOM node where we set state. - * @param stateName State attribute being set. - * Automatically adds prefix 'aria-' to the state name if the attribute is - * not an extra attribute. - * @param value Value for the state attribute. + * Note that the type of value is not validated against the specific type of + * state being changed, so it's up to callers to ensure the correct value is + * used for the given state. + * + * @param element The element whose ARIA state may be changed. + * @param stateName The state to change. + * @param value The new value to specify for the provided state. */ export function setState( element: Element, @@ -156,3 +164,44 @@ export function setState( const attrStateName = ARIA_PREFIX + stateName; element.setAttribute(attrStateName, `${value}`); } + +/** + * Returns a string representation of the specified state for the specified + * element, or null if it's not defined or specified. + * + * Note that an explicit set state of 'null' will return the 'null' string, not + * the value null. + * + * @param element The element whose state is being retrieved. + * @param stateName The state to retrieve. + * @returns The string representation of the requested state for the specified + * element, or null if not defined. + */ +export function getState(element: Element, stateName: State): string | null { + const attrStateName = ARIA_PREFIX + stateName; + return element.getAttribute(attrStateName); +} + +/** + * Softly requests that the specified text be read to the user if a screen + * reader is currently active. + * + * This relies on a centrally managed ARIA live region that should not interrupt + * existing announcements (that is, this is what's considered a polite + * announcement). + * + * Callers should use this judiciously. It's often considered bad practice to + * over announce information that can be inferred from other sources on the + * page, so this ought to only be used when certain context cannot be easily + * determined (such as dynamic states that may not have perfect ARIA + * representations or indications). + * + * @param text The text to politely read to the user. + */ +export function announceDynamicAriaState(text: string) { + const ariaAnnouncementSpan = document.getElementById('blocklyAriaAnnounce'); + if (!ariaAnnouncementSpan) { + throw new Error('Expected element with id blocklyAriaAnnounce to exist.'); + } + ariaAnnouncementSpan.innerHTML = text; +} diff --git a/core/utils/dom.ts b/core/utils/dom.ts index 4087984151c..e32cad4d604 100644 --- a/core/utils/dom.ts +++ b/core/utils/dom.ts @@ -6,7 +6,8 @@ // Former goog.module ID: Blockly.utils.dom -import type {Svg} from './svg.js'; +import * as aria from './aria.js'; +import {Svg} from './svg.js'; /** * Required name space for SVG elements. @@ -62,6 +63,9 @@ export function createSvgElement( if (opt_parent) { opt_parent.appendChild(e); } + if (name === Svg.SVG || name === Svg.G) { + aria.setRole(e, aria.Role.PRESENTATION); + } return e; } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index af395b077e5..2292b9b5591 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -31,6 +31,7 @@ import {WorkspaceComment} from './comments/workspace_comment.js'; import * as common from './common.js'; import {ComponentManager} from './component_manager.js'; import {ConnectionDB} from './connection_db.js'; +import {ConnectionType} from './connection_type.js'; import * as ContextMenu from './contextmenu.js'; import { ContextMenuOption, @@ -763,13 +764,21 @@ export class WorkspaceSvg 'class': 'blocklyWorkspace', 'id': this.id, }); + + let ariaLabel = null; if (injectionDiv) { - aria.setState( - this.svgGroup_, - aria.State.LABEL, - Msg['WORKSPACE_ARIA_LABEL'], - ); + ariaLabel = Msg['WORKSPACE_ARIA_LABEL']; + } else if (this.isFlyout) { + ariaLabel = 'Flyout'; + } else if (this.isMutator) { + ariaLabel = 'Mutator'; + } else { + // This case can happen in some test scenarios. + // TODO: Figure out when this can happen in non-test scenarios (if ever). + ariaLabel = 'Workspace'; } + aria.setState(this.svgGroup_, aria.State.LABEL, ariaLabel); + aria.setRole(this.svgGroup_, aria.Role.TREE); // Note that a alone does not receive mouse events--it must have a // valid target inside it. If no background class is specified, as in the @@ -2936,6 +2945,53 @@ export class WorkspaceSvg setNavigator(newNavigator: Navigator) { this.navigator = newNavigator; } + + recomputeAriaTree() { + // TODO: Do this efficiently (probably incrementally). + this.getTopBlocks(false).forEach((block) => + this.recomputeAriaTreeItemDetailsRecursively(block), + ); + } + + private recomputeAriaTreeItemDetailsRecursively(block: BlockSvg) { + const elem = block.getFocusableElement(); + const connection = block.currentConnectionCandidate; + let childPosition: number; + let parentsChildCount: number; + let hierarchyDepth: number; + if (connection) { + // If the block is being inserted into a new location, the position is hypothetical. + // TODO: Figure out how to deal with output connections. + let surroundParent: BlockSvg | null; + let siblingBlocks: BlockSvg[]; + if (connection.type === ConnectionType.INPUT_VALUE) { + surroundParent = connection.sourceBlock_; + siblingBlocks = block.collectSiblingBlocks(surroundParent); + // The block is being added as a child since it's input. + // TODO: Figure out how to compute the correct position. + childPosition = 0; + } else { + surroundParent = connection.sourceBlock_.getSurroundParent(); + siblingBlocks = block.collectSiblingBlocks(surroundParent); + // The block is being added after the connected block. + childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 1; + } + parentsChildCount = siblingBlocks.length + 1; + hierarchyDepth = surroundParent?.computeLevelInWorkspace() ?? 0; + } else { + const surroundParent = block.getSurroundParent(); + const siblingBlocks = block.collectSiblingBlocks(surroundParent); + childPosition = siblingBlocks.indexOf(block); + parentsChildCount = siblingBlocks.length; + hierarchyDepth = block.computeLevelInWorkspace(); + } + aria.setState(elem, aria.State.POSINSET, childPosition + 1); + aria.setState(elem, aria.State.SETSIZE, parentsChildCount); + aria.setState(elem, aria.State.LEVEL, hierarchyDepth + 1); + block + .getChildren(false) + .forEach((child) => this.recomputeAriaTreeItemDetailsRecursively(child)); + } } /** From 4be1ddfb0ee4e241324c165fa277ca81b0e1990d Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 11 Aug 2025 14:59:16 -0700 Subject: [PATCH 02/51] fix: Keyboard nav CI workflows by pointing to experimentation branch (experimental) (#9284) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9283 ### Proposed Changes Updates the keyboard navigation plugin workflow to point to the screen reader experimentation branch. ### Reason for Changes This ensures that both the plugin (via https://github.com/google/blockly-keyboard-experimentation/pull/684) and core Blockly correctly link their experimental screen reader branches against each other to ensure CI passes correctly for both. ### Test Coverage No new tests are needed here. ### Documentation N/A ### Additional Information Similar to #9280, this PR will never be merged directly into `develop` so the workflow changes will remain isolated to the experimental branch. --- .github/workflows/keyboard_plugin_test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/keyboard_plugin_test.yml b/.github/workflows/keyboard_plugin_test.yml index 753d31dda1e..d7fc1976cd2 100644 --- a/.github/workflows/keyboard_plugin_test.yml +++ b/.github/workflows/keyboard_plugin_test.yml @@ -7,7 +7,7 @@ on: pull_request: push: branches: - - develop + - add-screen-reader-support-experimental permissions: contents: read @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@v4 with: repository: 'google/blockly-keyboard-experimentation' - ref: 'main' + ref: 'add-screen-reader-support-experimental' path: blockly-keyboard-experimentation - name: Use Node.js 20.x From d349a0c0b0477d6032b262f596f4328ad0f11c28 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 10 Sep 2025 11:44:08 -0700 Subject: [PATCH 03/51] fix: Improve representation of `FlyoutButtons` in screenreaders. (#9356) * fix: Improve representation of `FlyoutButtons` in screenreaders. * fix: Remove aria-selected. --- core/flyout_button.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/core/flyout_button.ts b/core/flyout_button.ts index 405d7833386..ce82b5f9bf3 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -114,12 +114,17 @@ export class FlyoutButton this.id = idGenerator.getNextUniqueId(); this.svgGroup = dom.createSvgElement( Svg.G, - {'id': this.id, 'class': cssClass}, + {'id': this.id, 'class': cssClass, 'tabindex': '-1'}, this.workspace.getCanvas(), ); - aria.setRole(this.svgGroup, aria.Role.BUTTON); - aria.setState(this.svgGroup, aria.State.LABEL, 'Button'); + // Set the accessibility role. All child nodes will be set to `presentation` + // to avoid extraneous "group" narration on VoiceOver. + if (this.isFlyoutLabel) { + aria.setRole(this.svgGroup, aria.Role.TREEITEM); + } else { + aria.setRole(this.svgGroup, aria.Role.BUTTON); + } let shadow; if (!this.isFlyoutLabel) { @@ -135,6 +140,7 @@ export class FlyoutButton }, this.svgGroup!, ); + aria.setRole(shadow, aria.Role.PRESENTATION); } // Background rectangle. const rect = dom.createSvgElement( @@ -148,6 +154,7 @@ export class FlyoutButton }, this.svgGroup!, ); + aria.setRole(rect, aria.Role.PRESENTATION); const svgText = dom.createSvgElement( Svg.TEXT, @@ -159,6 +166,9 @@ export class FlyoutButton }, this.svgGroup!, ); + if (!this.isFlyoutLabel) { + 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. From f682607a6c8759cade7f66261db580030601198d Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 12 Sep 2025 14:25:33 -0700 Subject: [PATCH 04/51] fix: Use field ARIA labels for field-only blocks. (#9361) * fix: Use field ARIA labels for field-only blocks. * chore: Make the linter happy. --- core/block_svg.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index d90e1ad2ac7..295b806961b 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -224,9 +224,6 @@ export class BlockSvg this.currentConnectionCandidate = null; this.doInit_(); - - // Note: This must be done after initialization of the block's fields. - this.recomputeAriaLabel(); } private recomputeAriaLabel() { @@ -239,15 +236,19 @@ export class BlockSvg private computeAriaLabel(): string { // Guess the block's aria label based on its field labels. - if (this.isShadow()) { + if (this.isShadow() || this.isSimpleReporter()) { // TODO: Shadows may have more than one field. // Shadow blocks are best represented directly by their field since they // effectively operate like a field does for keyboard navigation purposes. const field = Array.from(this.getFields())[0]; - return ( - aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? - 'Unknown?' - ); + try { + return ( + aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? + 'Unknown?' + ); + } catch { + return 'Unknown?'; + } } const fieldLabels = []; @@ -306,6 +307,8 @@ export class BlockSvg if (!svg.parentNode) { this.workspace.getCanvas().appendChild(svg); } + // Note: This must be done after initialization of the block's fields. + this.recomputeAriaLabel(); this.initialized = true; } From 3fdfe479703146e60e8aaafd292331f569c5801c Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 15 Sep 2025 11:40:07 -0700 Subject: [PATCH 05/51] fix: Avoid creating redundant a11y tree nodes when selecting a block. (#9363) --- core/renderers/zelos/path_object.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/core/renderers/zelos/path_object.ts b/core/renderers/zelos/path_object.ts index 3c304fd6bf8..1804a23f9b7 100644 --- a/core/renderers/zelos/path_object.ts +++ b/core/renderers/zelos/path_object.ts @@ -8,8 +8,8 @@ import type {BlockSvg} from '../../block_svg.js'; import type {Connection} from '../../connection.js'; -import {FocusManager} from '../../focus_manager.js'; import type {BlockStyle} from '../../theme.js'; +import * as aria from '../../utils/aria.js'; import * as dom from '../../utils/dom.js'; import {Svg} from '../../utils/svg.js'; import {PathObject as BasePathObject} from '../common/path_object.js'; @@ -90,20 +90,17 @@ export class PathObject extends BasePathObject { this.setClass_('blocklySelected', enable); if (enable) { if (!this.svgPathSelected) { - this.svgPathSelected = this.svgPath.cloneNode(true) as SVGElement; - this.svgPathSelected.classList.add('blocklyPathSelected'); - // Ensure focus-specific properties don't overlap with the block's path. - dom.removeClass( - this.svgPathSelected, - FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, - ); - dom.removeClass( - this.svgPathSelected, - FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + this.svgPathSelected = dom.createSvgElement( + Svg.PATH, + { + 'class': 'blocklyPath blocklyPathSelected', + 'stroke': this.svgPath.getAttribute('stroke') ?? '', + 'fill': this.svgPath.getAttribute('fill') ?? '', + 'd': this.svgPath.getAttribute('d') ?? '', + 'role': aria.Role.PRESENTATION, + }, + this.svgRoot, ); - this.svgPathSelected.removeAttribute('tabindex'); - this.svgPathSelected.removeAttribute('id'); - this.svgRoot.appendChild(this.svgPathSelected); } } else { if (this.svgPathSelected) { From 8da7d1794ba0c5bf7291c98ade6adfbc24af87d2 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 16 Sep 2025 09:01:51 -0700 Subject: [PATCH 06/51] fix: account for undefined first sibling (#9368) --- core/block_svg.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 295b806961b..a22af525cc0 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -269,7 +269,7 @@ export class BlockSvg const firstSibling: BlockSvg = surroundParent.getChildren(false)[0]; const siblings: BlockSvg[] = [firstSibling]; let nextSibling: BlockSvg | null = firstSibling; - while ((nextSibling = nextSibling.getNextBlock())) { + while ((nextSibling = nextSibling?.getNextBlock())) { siblings.push(nextSibling); } return siblings; From 0cc093553e5f6f046ad47bef2ee4846d80e02db0 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 17 Sep 2025 12:33:57 -0700 Subject: [PATCH 07/51] chore: use beta kb nav in advanced playground (#9369) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8dc8efdbcba..29a0ce7a6c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@blockly/block-test": "^7.0.2", "@blockly/dev-tools": "^9.0.2", - "@blockly/keyboard-navigation": "^3.0.1", + "@blockly/keyboard-navigation": "^4.0.0-beta.0", "@blockly/theme-modern": "^7.0.1", "@hyperjump/browser": "^1.1.4", "@hyperjump/json-schema": "^1.5.0", @@ -201,9 +201,9 @@ } }, "node_modules/@blockly/keyboard-navigation": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@blockly/keyboard-navigation/-/keyboard-navigation-3.0.1.tgz", - "integrity": "sha512-qSOPqsqRgkSLEoUeEZc81PWe558pXqY0e+4jkRODoAD+I1hMpCoD+6ivveRp7Jpb8WE1lj2PrAFOVuIVpphjHA==", + "version": "4.0.0-beta.0", + "resolved": "https://registry.npmjs.org/@blockly/keyboard-navigation/-/keyboard-navigation-4.0.0-beta.0.tgz", + "integrity": "sha512-jeFHyXNrFMFk6ZFlcdjJvmSXkiq8w/y/h49pZSe3E6o01pUtuVVf4PEUKj1KW2v+F0IwlhyHY5cvPu+JudyZKg==", "dev": true, "peerDependencies": { "blockly": "^12.3.0" diff --git a/package.json b/package.json index b2892977c91..62c4b6520a4 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "devDependencies": { "@blockly/block-test": "^7.0.2", "@blockly/dev-tools": "^9.0.2", - "@blockly/keyboard-navigation": "^3.0.1", + "@blockly/keyboard-navigation": "^4.0.0-beta.0", "@blockly/theme-modern": "^7.0.1", "@hyperjump/browser": "^1.1.4", "@hyperjump/json-schema": "^1.5.0", From 27fe7644283cc2bd1072615a576a6551877748a9 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Thu, 18 Sep 2025 14:27:41 -0700 Subject: [PATCH 08/51] fix: improve screenreader output for workspace comments (#9351) * fix: improve screenreader output for workspace comments * fix: run npm run messages to fully add new message * fix: remove useless bubble label * fix: use roledescription instead of description --- core/bubbles/bubble.ts | 1 - core/comments/collapse_comment_bar_button.ts | 4 +++- core/comments/comment_editor.ts | 1 - core/comments/comment_view.ts | 9 ++++++--- core/comments/delete_comment_bar_button.ts | 4 +++- msg/json/en.json | 3 ++- msg/json/qqq.json | 1 + msg/messages.js | 3 +++ 8 files changed, 18 insertions(+), 8 deletions(-) diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index 26ca2f40b10..8a6bacac54d 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -144,7 +144,6 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { this.focusableElement = overriddenFocusableElement ?? this.svgRoot; this.focusableElement.setAttribute('id', this.id); aria.setRole(this.focusableElement, aria.Role.GROUP); - aria.setState(this.focusableElement, aria.State.LABEL, 'Bubble'); browserEvents.conditionalBind( this.background, diff --git a/core/comments/collapse_comment_bar_button.ts b/core/comments/collapse_comment_bar_button.ts index f653e8d53e9..ee4133aba2f 100644 --- a/core/comments/collapse_comment_bar_button.ts +++ b/core/comments/collapse_comment_bar_button.ts @@ -5,6 +5,7 @@ */ import * as browserEvents from '../browser_events.js'; +import {Msg} from '../msg.js'; import * as touch from '../touch.js'; import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; @@ -57,6 +58,7 @@ export class CollapseCommentBarButton extends CommentBarButton { }, this.container, ); + this.initAria(); this.bindId = browserEvents.conditionalBind( this.icon, 'pointerdown', @@ -74,7 +76,7 @@ export class CollapseCommentBarButton extends CommentBarButton { override initAria(): void { aria.setRole(this.icon, aria.Role.BUTTON); - aria.setState(this.icon, aria.State.LABEL, 'DoNotDefine?'); + aria.setState(this.icon, aria.State.LABEL, Msg['COLLAPSE_COMMENT']); } /** diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts index e1a4f5557ec..a5ce260a985 100644 --- a/core/comments/comment_editor.ts +++ b/core/comments/comment_editor.ts @@ -58,7 +58,6 @@ export class CommentEditor implements IFocusableNode { this.textArea.setAttribute('tabindex', '-1'); this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR'); aria.setRole(this.textArea, aria.Role.TEXTBOX); - aria.setState(this.textArea, aria.State.LABEL, 'DoNotDefine?'); dom.addClass(this.textArea, 'blocklyCommentText'); dom.addClass(this.textArea, 'blocklyTextarea'); dom.addClass(this.textArea, 'blocklyText'); diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 7258a7efb0a..a63971122fd 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -109,9 +109,6 @@ export class CommentView implements IRenderedElement { 'class': 'blocklyComment blocklyEditable blocklyDraggable', }); - aria.setRole(this.svgRoot, aria.Role.TEXTBOX); - aria.setState(this.svgRoot, aria.State.LABEL, 'DoNotOverride?'); - this.highlightRect = this.createHighlightRect(this.svgRoot); ({ @@ -127,6 +124,12 @@ export class CommentView implements IRenderedElement { this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace); + aria.setRole(this.svgRoot, aria.Role.BUTTON); + if (this.commentEditor.id) { + aria.setState(this.svgRoot, aria.State.LABELLEDBY, this.commentEditor.id); + } + aria.setState(this.svgRoot, aria.State.ROLEDESCRIPTION, 'Comment'); + // TODO: Remove this comment before merging. // I think we want comments to exist on the same layer as blocks. workspace.getLayerManager()?.append(this, layers.BLOCK); diff --git a/core/comments/delete_comment_bar_button.ts b/core/comments/delete_comment_bar_button.ts index 16a1d5b80b2..f6096d175db 100644 --- a/core/comments/delete_comment_bar_button.ts +++ b/core/comments/delete_comment_bar_button.ts @@ -6,6 +6,7 @@ import * as browserEvents from '../browser_events.js'; import {getFocusManager} from '../focus_manager.js'; +import {Msg} from '../msg.js'; import * as touch from '../touch.js'; import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; @@ -57,6 +58,7 @@ export class DeleteCommentBarButton extends CommentBarButton { }, container, ); + this.initAria(); this.bindId = browserEvents.conditionalBind( this.icon, 'pointerdown', @@ -74,7 +76,7 @@ export class DeleteCommentBarButton extends CommentBarButton { override initAria(): void { aria.setRole(this.icon, aria.Role.BUTTON); - aria.setState(this.icon, aria.State.LABEL, 'DoNotDefine?'); + aria.setState(this.icon, aria.State.LABEL, Msg['REMOVE_COMMENT']); } /** diff --git a/msg/json/en.json b/msg/json/en.json index ec5862ae465..b517da8002e 100644 --- a/msg/json/en.json +++ b/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2025-09-08 16:26:57.642330", + "lastupdated": "2025-09-09 09:40:56.729862", "locale": "en", "messagedocumentation" : "qqq" }, @@ -12,6 +12,7 @@ "ADD_COMMENT": "Add Comment", "REMOVE_COMMENT": "Remove Comment", "DUPLICATE_COMMENT": "Duplicate Comment", + "COLLAPSE_COMMENT": "Collapse Comment", "EXTERNAL_INPUTS": "External Inputs", "INLINE_INPUTS": "Inline Inputs", "DELETE_BLOCK": "Delete Block", diff --git a/msg/json/qqq.json b/msg/json/qqq.json index 5e03efc4153..69228de715e 100644 --- a/msg/json/qqq.json +++ b/msg/json/qqq.json @@ -19,6 +19,7 @@ "ADD_COMMENT": "context menu - Add a descriptive comment to the selected block.", "REMOVE_COMMENT": "context menu - Remove the descriptive comment from the selected block.", "DUPLICATE_COMMENT": "context menu - Make a copy of the selected workspace comment.\n{{Identical|Duplicate}}", + "COLLAPSE_COMMENT": "context menu - Collapse the selected workspace comment.", "EXTERNAL_INPUTS": "context menu - Change from 'external' to 'inline' mode for displaying blocks used as inputs to the selected block. See [[Translating:Blockly#context_menus]].\n\nThe opposite of {{msg-blockly|INLINE INPUTS}}.", "INLINE_INPUTS": "context menu - Change from 'internal' to 'external' mode for displaying blocks used as inputs to the selected block. See [[Translating:Blockly#context_menus]].\n\nThe opposite of {{msg-blockly|EXTERNAL INPUTS}}.", "DELETE_BLOCK": "context menu - Permanently delete the selected block.", diff --git a/msg/messages.js b/msg/messages.js index 1095ae05776..28ef830c484 100644 --- a/msg/messages.js +++ b/msg/messages.js @@ -85,6 +85,9 @@ Blockly.Msg.REMOVE_COMMENT = 'Remove Comment'; /// context menu - Make a copy of the selected workspace comment.\n{{Identical|Duplicate}} Blockly.Msg.DUPLICATE_COMMENT = 'Duplicate Comment'; /** @type {string} */ +/// context menu - Collapse the selected workspace comment. +Blockly.Msg.COLLAPSE_COMMENT = 'Collapse Comment'; +/** @type {string} */ /// context menu - Change from 'external' to 'inline' mode for displaying blocks used as inputs to the selected block. See [[Translating:Blockly#context_menus]].\n\nThe opposite of {{msg-blockly|INLINE INPUTS}}. Blockly.Msg.EXTERNAL_INPUTS = 'External Inputs'; /** @type {string} */ From 346716a3a8c375d91db41c778440edbed9495161 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Mon, 22 Sep 2025 09:55:32 -0700 Subject: [PATCH 09/51] fix: exclude svg root from aria tree (#9370) --- core/flyout_base.ts | 4 ++++ core/inject.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 492d3341762..843d25de5b3 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -33,6 +33,7 @@ import * as renderManagement from './render_management.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import {SEPARATOR_TYPE} from './separator_flyout_inflater.js'; import * as blocks from './serialization/blocks.js'; +import {aria} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as idGenerator from './utils/idgenerator.js'; @@ -309,6 +310,9 @@ export abstract class Flyout 'class': 'blocklyFlyout', }); this.svgGroup_.style.display = 'none'; + // Ignore the svg root in the accessibility tree since is is not focusable. + aria.setRole(this.svgGroup_, aria.Role.PRESENTATION); + this.svgBackground_ = dom.createSvgElement( Svg.PATH, {'class': 'blocklyFlyoutBackground'}, diff --git a/core/inject.ts b/core/inject.ts index 3488f669d61..eeeddb282c3 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -55,6 +55,9 @@ export function inject( dom.addClass(subContainer, 'blocklyRTL'); } + // Ignore the subcontainer in aria since it is not focusable + aria.setRole(subContainer, aria.Role.PRESENTATION); + containerElement!.appendChild(subContainer); const svg = createDom(subContainer, options); From 1dd77015e3abf3d4aad8eb90074f17ce46ee30a6 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 22 Sep 2025 13:27:55 -0700 Subject: [PATCH 10/51] fix: Add a label to the toolbox. (#9378) --- core/toolbox/toolbox.ts | 2 ++ msg/json/en.json | 3 ++- msg/json/qqq.json | 1 + msg/messages.js | 7 ++++++- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 3b87f1ef2c2..e6268b84616 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -35,6 +35,7 @@ import {isSelectableToolboxItem} from '../interfaces/i_selectable_toolbox_item.j import type {IStyleable} from '../interfaces/i_styleable.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import type {IToolboxItem} from '../interfaces/i_toolbox_item.js'; +import {Msg} from '../msg.js'; import * as registry from '../registry.js'; import type {KeyboardShortcut} from '../shortcut_registry.js'; import * as Touch from '../touch.js'; @@ -154,6 +155,7 @@ export class Toolbox this.flyout.init(workspace); aria.setRole(this.HtmlDiv, aria.Role.TREE); + aria.setState(this.HtmlDiv, aria.State.LABEL, Msg['TOOLBOX_ARIA_LABEL']); this.render(this.toolboxDef_); const themeManager = workspace.getThemeManager(); diff --git a/msg/json/en.json b/msg/json/en.json index b517da8002e..a589674d206 100644 --- a/msg/json/en.json +++ b/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2025-09-09 09:40:56.729862", + "lastupdated": "2025-09-22 11:22:54.733649", "locale": "en", "messagedocumentation" : "qqq" }, @@ -396,6 +396,7 @@ "PROCEDURES_IFRETURN_WARNING": "Warning: This block may be used only within a function definition.", "WORKSPACE_COMMENT_DEFAULT_TEXT": "Say something...", "WORKSPACE_ARIA_LABEL": "Blockly Workspace", + "TOOLBOX_ARIA_LABEL": "Toolbox", "COLLAPSED_WARNINGS_WARNING": "Collapsed blocks contain warnings.", "DIALOG_OK": "OK", "DIALOG_CANCEL": "Cancel", diff --git a/msg/json/qqq.json b/msg/json/qqq.json index 69228de715e..0f69fdda7e1 100644 --- a/msg/json/qqq.json +++ b/msg/json/qqq.json @@ -403,6 +403,7 @@ "PROCEDURES_IFRETURN_WARNING": "warning - This appears if the user tries to use this block outside of a function definition.", "WORKSPACE_COMMENT_DEFAULT_TEXT": "comment text - This text appears in a new workspace comment, to hint that the user can type here.", "WORKSPACE_ARIA_LABEL": "workspace - This text is read out when a user navigates to the workspace while using a screen reader.", + "TOOLBOX_ARIA_LABEL": "This text is read out when a user navigates to the toolbox while using a screen reader.", "COLLAPSED_WARNINGS_WARNING": "warning - This appears if the user collapses a block, and blocks inside that block have warnings attached to them. It should inform the user that the block they collapsed contains blocks that have warnings.", "DIALOG_OK": "button label - Pressing this button closes help information.\n{{Identical|OK}}", "DIALOG_CANCEL": "button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}}", diff --git a/msg/messages.js b/msg/messages.js index 28ef830c484..83c8bda0e29 100644 --- a/msg/messages.js +++ b/msg/messages.js @@ -1607,6 +1607,11 @@ Blockly.Msg.WORKSPACE_COMMENT_DEFAULT_TEXT = 'Say something...'; /// using a screen reader. Blockly.Msg.WORKSPACE_ARIA_LABEL = 'Blockly Workspace'; +/** @type {string} */ +/// This text is read out when a user navigates to the toolbox while using a +/// screen reader. +Blockly.Msg.TOOLBOX_ARIA_LABEL = 'Toolbox'; + /** @type {string} */ /// warning - This appears if the user collapses a block, and blocks inside /// that block have warnings attached to them. It should inform the user that the @@ -1629,7 +1634,7 @@ Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents'; /// menu label - Contextual menu item that starts a keyboard-driven block move. Blockly.Msg.MOVE_BLOCK = 'Move Block'; /** @type {string} */ -/// Name of the Microsoft Windows operating system displayed in a list of +/// Name of the Microsoft Windows operating system displayed in a list of /// keyboard shortcuts. Blockly.Msg.WINDOWS = 'Windows'; /** @type {string} */ From 76c734598bfe3b4ff0cf31237d3064c528912ae0 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 1 Oct 2025 15:52:07 -0700 Subject: [PATCH 11/51] fix: Toolbox & Flyout ARIA positions (experimental) (#9394) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9386 Fixes part of #9293 ### Proposed Changes Addresses #9386 through a number of changes: - Ensures the flyout contents are reevaluated for ARIA changes whenever they themselves change (since previously `WorkspaceSvg` only recomputed its ARIA properties when one of its blocks self-registered which doesn't account for labels or buttons). - Collapsible categories are now correctly wrapped by a group (since groups of tree items must be in a parent group). - Updates toolbox ARIA computations to be on content changes rather than having items self-specify recomputing the ARIA properties. This mitigates an issue with collapsible categories not updating the toolbox contents and thus being omitted. - Introduced a separate pathway for computing tree info for flyout workspaces (vs. for the main workspace) in order to account for `FlyoutButton`s. - Updated `FlyoutButton` to use a nested structure of `treeitem` then `button` in order to actually count correctly in the tree and still be an interactive button. The readout experience is actually better now on ChromeVox, as well, since it reads out _both_ 'Button' and 'Tree item' which is interesting. It seems screen readers are designed to look for this pattern but it only works if set up in a very particular way. ### Reason for Changes Most of the changes here fixed incidental problems noticed while trying to address #9386 (such as the variables category not correctly accounting for the 'Create variable' button in the count, or not having the correct labels). Much of the count issues in the original issue were caused by a combination of missing some flyout items, and trying to compute the labels too early (i.e. before the categories were fully populated). ### Test Coverage Since this is an experimental change, no new tests were added. ### Documentation No documentation changes are directly needed here. ### Additional Information None. --- core/block_svg.ts | 2 +- core/flyout_base.ts | 2 + core/flyout_button.ts | 57 ++++++++++++++++---------- core/toolbox/category.ts | 2 - core/toolbox/collapsible_category.ts | 6 +-- core/toolbox/separator.ts | 2 - core/toolbox/toolbox.ts | 2 + core/workspace_svg.ts | 60 +++++++++++++++++++--------- 8 files changed, 84 insertions(+), 49 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index a22af525cc0..69303309031 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -262,7 +262,7 @@ export class BlockSvg collectSiblingBlocks(surroundParent: BlockSvg | null): BlockSvg[] { // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The - // returned list needs to be relatively stable for consistency block indexes + // returned list needs to be relatively stable for consistent block indexes // read out to users via screen readers. if (surroundParent) { // Start from the first sibling and iterate in navigation order. diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 843d25de5b3..7caf98c9b2d 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -534,7 +534,9 @@ export abstract class Flyout */ setContents(contents: FlyoutItem[]): void { this.contents = contents; + this.workspace_.recomputeAriaTree(); } + /** * Update the display property of the flyout based whether it thinks it should * be visible and whether its containing workspace is visible. diff --git a/core/flyout_button.ts b/core/flyout_button.ts index ce82b5f9bf3..607825f0516 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -60,7 +60,13 @@ export class FlyoutButton height = 0; /** The root SVG group for the button or label. */ - private svgGroup: SVGGElement; + private svgContainerGroup: SVGGElement; + + /** The root SVG group for the button's or label's contents. */ + private svgContentGroup: SVGGElement; + + /** The SVG group that can hold focus for this button or label. */ + private svgFocusableGroup: SVGGElement; /** The SVG element with the text of the label or button. */ private svgText: SVGTextElement | null = null; @@ -112,19 +118,27 @@ export class FlyoutButton } this.id = idGenerator.getNextUniqueId(); - this.svgGroup = dom.createSvgElement( + this.svgContainerGroup = dom.createSvgElement( Svg.G, - {'id': this.id, 'class': cssClass, 'tabindex': '-1'}, + {'class': cssClass}, this.workspace.getCanvas(), ); + this.svgContentGroup = dom.createSvgElement( + Svg.G, + {}, + this.svgContainerGroup, + ); - // Set the accessibility role. All child nodes will be set to `presentation` - // to avoid extraneous "group" narration on VoiceOver. + aria.setRole(this.svgContainerGroup, aria.Role.TREEITEM); if (this.isFlyoutLabel) { - aria.setRole(this.svgGroup, aria.Role.TREEITEM); + aria.setRole(this.svgContentGroup, aria.Role.PRESENTATION); + this.svgFocusableGroup = this.svgContainerGroup; } else { - aria.setRole(this.svgGroup, aria.Role.BUTTON); + aria.setRole(this.svgContentGroup, aria.Role.BUTTON); + this.svgFocusableGroup = this.svgContentGroup; } + this.svgFocusableGroup.id = this.id; + this.svgFocusableGroup.tabIndex = -1; let shadow; if (!this.isFlyoutLabel) { @@ -138,7 +152,7 @@ export class FlyoutButton 'x': 1, 'y': 1, }, - this.svgGroup!, + this.svgContentGroup, ); aria.setRole(shadow, aria.Role.PRESENTATION); } @@ -152,7 +166,7 @@ export class FlyoutButton 'rx': FlyoutButton.BORDER_RADIUS, 'ry': FlyoutButton.BORDER_RADIUS, }, - this.svgGroup!, + this.svgContentGroup, ); aria.setRole(rect, aria.Role.PRESENTATION); @@ -164,7 +178,7 @@ export class FlyoutButton 'y': 0, 'text-anchor': 'middle', }, - this.svgGroup!, + this.svgContentGroup, ); if (!this.isFlyoutLabel) { aria.setRole(svgText, aria.Role.PRESENTATION); @@ -181,6 +195,7 @@ export class FlyoutButton .getThemeManager() .subscribe(this.svgText, 'flyoutForegroundColour', 'fill'); } + aria.setState(this.svgFocusableGroup, aria.State.LABEL, text); const fontSize = style.getComputedStyle(svgText, 'fontSize'); const fontWeight = style.getComputedStyle(svgText, 'fontWeight'); @@ -217,13 +232,13 @@ export class FlyoutButton this.updateTransform(); this.onMouseDownWrapper = browserEvents.conditionalBind( - this.svgGroup, + this.svgContentGroup, 'pointerdown', this, this.onMouseDown, ); this.onMouseUpWrapper = browserEvents.conditionalBind( - this.svgGroup, + this.svgContentGroup, 'pointerup', this, this.onMouseUp, @@ -233,18 +248,18 @@ export class FlyoutButton createDom(): SVGElement { // No-op, now handled in constructor. Will be removed in followup refactor // PR that updates the flyout classes to use inflaters. - return this.svgGroup; + return this.svgContainerGroup; } /** Correctly position the flyout button and make it visible. */ show() { this.updateTransform(); - this.svgGroup!.setAttribute('display', 'block'); + this.svgContainerGroup!.setAttribute('display', 'block'); } /** Update SVG attributes to match internal state. */ private updateTransform() { - this.svgGroup!.setAttribute( + this.svgContainerGroup!.setAttribute( 'transform', 'translate(' + this.position.x + ',' + this.position.y + ')', ); @@ -330,8 +345,8 @@ export class FlyoutButton dispose() { browserEvents.unbind(this.onMouseDownWrapper); browserEvents.unbind(this.onMouseUpWrapper); - if (this.svgGroup) { - dom.removeNode(this.svgGroup); + if (this.svgContainerGroup) { + dom.removeNode(this.svgContainerGroup); } if (this.svgText) { this.workspace.getThemeManager().unsubscribe(this.svgText); @@ -349,8 +364,8 @@ export class FlyoutButton this.cursorSvg = null; return; } - if (this.svgGroup) { - this.svgGroup.appendChild(cursorSvg); + if (this.svgContainerGroup) { + this.svgContentGroup.appendChild(cursorSvg); this.cursorSvg = cursorSvg; } } @@ -398,12 +413,12 @@ export class FlyoutButton * @returns The root SVG element of this rendered element. */ getSvgRoot() { - return this.svgGroup; + return this.svgContainerGroup; } /** See IFocusableNode.getFocusableElement. */ getFocusableElement(): HTMLElement | SVGElement { - return this.svgGroup; + return this.svgFocusableGroup; } /** See IFocusableNode.getFocusableTree. */ diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index d86a41cb613..7b0db7b3fcd 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -30,7 +30,6 @@ import type { StaticCategoryInfo, } from '../utils/toolbox.js'; import * as toolbox from '../utils/toolbox.js'; -import {Toolbox} from './toolbox.js'; import {ToolboxItem} from './toolbox_item.js'; /** @@ -193,7 +192,6 @@ export class ToolboxCategory aria.setRole(this.htmlDiv_, aria.Role.TREEITEM); aria.setState(this.htmlDiv_, aria.State.SELECTED, false); aria.setState(this.htmlDiv_, aria.State.LEVEL, this.level_ + 1); - (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); this.rowDiv_ = this.createRowContainer_(); this.rowDiv_.style.pointerEvents = 'auto'; diff --git a/core/toolbox/collapsible_category.ts b/core/toolbox/collapsible_category.ts index 342230fdd1b..7f8d8a915d8 100644 --- a/core/toolbox/collapsible_category.ts +++ b/core/toolbox/collapsible_category.ts @@ -20,7 +20,6 @@ import * as dom from '../utils/dom.js'; import * as toolbox from '../utils/toolbox.js'; import {ToolboxCategory} from './category.js'; import {ToolboxSeparator} from './separator.js'; -import {Toolbox} from './toolbox.js'; /** * Class for a category in a toolbox that can be collapsed. @@ -136,8 +135,7 @@ export class CollapsibleToolboxCategory this.htmlDiv_!.appendChild(this.subcategoriesDiv_); this.closeIcon_(this.iconDom_); aria.setState(this.htmlDiv_ as HTMLDivElement, aria.State.EXPANDED, false); - - aria.setRole(this.htmlDiv_!, aria.Role.GROUP); + aria.setRole(this.htmlDiv_!, aria.Role.TREEITEM); // Ensure this group has properly set children. const selectableChildren = @@ -150,7 +148,6 @@ export class CollapsibleToolboxCategory aria.State.OWNS, [...new Set(focusableChildIds)].join(' '), ); - (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); return this.htmlDiv_!; } @@ -194,6 +191,7 @@ export class CollapsibleToolboxCategory newCategory.getClickTarget()?.setAttribute('id', newCategory.getId()); } } + aria.setRole(contentsContainer, aria.Role.GROUP); return contentsContainer; } diff --git a/core/toolbox/separator.ts b/core/toolbox/separator.ts index 9c2b5e6e2d2..517ed16016f 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -17,7 +17,6 @@ import * as registry from '../registry.js'; import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import type * as toolbox from '../utils/toolbox.js'; -import {Toolbox} from './toolbox.js'; import {ToolboxItem} from './toolbox_item.js'; /** @@ -67,7 +66,6 @@ export class ToolboxSeparator extends ToolboxItem { this.htmlDiv = container; aria.setRole(this.htmlDiv, aria.Role.SEPARATOR); - (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); return container; } diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index e6268b84616..d006074d2a4 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -369,6 +369,7 @@ export class Toolbox this.renderContents_(toolboxDef['contents']); this.position(); this.handleToolboxItemResize(); + this.recomputeAriaOwners(); } /** @@ -445,6 +446,7 @@ export class Toolbox this.addToolboxItem_(child); } } + this.recomputeAriaOwners(); } /** diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 2292b9b5591..cc7da2fcf32 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -19,7 +19,7 @@ import './events/events_theme_change.js'; import './events/events_viewport.js'; import type {Block} from './block.js'; -import type {BlockSvg} from './block_svg.js'; +import {BlockSvg} from './block_svg.js'; import type {BlocklyOptions} from './blockly_options.js'; import * as browserEvents from './browser_events.js'; import {TextInputBubble} from './bubbles/textinput_bubble.js'; @@ -42,7 +42,7 @@ import {Abstract as AbstractEvent} from './events/events.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import {Flyout} from './flyout_base.js'; -import type {FlyoutButton} from './flyout_button.js'; +import {FlyoutButton} from './flyout_button.js'; import {getFocusManager} from './focus_manager.js'; import {Gesture} from './gesture.js'; import {Grid} from './grid.js'; @@ -52,10 +52,7 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {IDragTarget} from './interfaces/i_drag_target.js'; import type {IFlyout} from './interfaces/i_flyout.js'; -import { - isFocusableNode, - type IFocusableNode, -} from './interfaces/i_focusable_node.js'; +import {type IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {hasBubble} from './interfaces/i_has_bubble.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; @@ -2745,10 +2742,7 @@ export class WorkspaceSvg return ( flyout .getContents() - .find((flyoutItem) => { - const element = flyoutItem.getElement(); - return isFocusableNode(element) && element.canBeFocused(); - }) + .find((flyoutItem) => flyoutItem.getElement().canBeFocused()) ?.getElement() ?? null ); } @@ -2805,11 +2799,7 @@ export class WorkspaceSvg if (this.isFlyout && flyout) { for (const flyoutItem of flyout.getContents()) { const elem = flyoutItem.getElement(); - if ( - isFocusableNode(elem) && - elem.canBeFocused() && - elem.getFocusableElement().id === id - ) { + if (elem.canBeFocused() && elem.getFocusableElement().id === id) { return elem; } } @@ -2947,10 +2937,42 @@ export class WorkspaceSvg } recomputeAriaTree() { - // TODO: Do this efficiently (probably incrementally). - this.getTopBlocks(false).forEach((block) => - this.recomputeAriaTreeItemDetailsRecursively(block), - ); + // Flyout workspaces require special arrangement to account for items. + const flyout = this.targetWorkspace?.getFlyout(); + if (this.isFlyout && flyout) { + const focusableItems = flyout + .getContents() + .map((item) => item.getElement()) + .filter((item) => item.canBeFocused()); + focusableItems.forEach((item, index) => { + // This is rather hacky and may need more thought, but it's a + // consequence of actual button (non-label) FlyoutButtons requiring two + // distinct roles (a parent treeitem and a child button that actually + // holds focus). + // TODO: Figure out how to generalize this for arbitrary FlyoutItems + // that may require special handling like this (i.e. a treeitem wrapping + // an actual focusable element). + const treeItemElem = + item instanceof FlyoutButton + ? item.getSvgRoot() + : item.getFocusableElement(); + aria.setState(treeItemElem, aria.State.POSINSET, index + 1); + aria.setState(treeItemElem, aria.State.SETSIZE, focusableItems.length); + aria.setState(treeItemElem, aria.State.LEVEL, 1); // They are always top-level. + if (item instanceof BlockSvg) { + item + .getChildren(false) + .forEach((child) => + this.recomputeAriaTreeItemDetailsRecursively(child), + ); + } + }); + } else { + // TODO: Do this efficiently (probably incrementally). + this.getTopBlocks(false).forEach((block) => + this.recomputeAriaTreeItemDetailsRecursively(block), + ); + } } private recomputeAriaTreeItemDetailsRecursively(block: BlockSvg) { From 3cf834a6a6af582803b39b26838e9891695b82de Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 1 Oct 2025 16:19:06 -0700 Subject: [PATCH 12/51] feat: Fix ARIA roles and setup for fields (experimental) (#9384) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #8206 Fixes #8210 Fixes #8213 Fixes #8255 Fixes #8211 Fixes #8212 Fixes #8254 Fixes part of #9301 Fixes part of #9304 ### Proposed Changes This PR completes the remaining ARIA roles and properties needed for all core fields. Specifically: - #8206: A better name needed to be used for the checkbox value, plus there was an ARIA property missing for actually representing the checkbox state. The latter needed to be updated upon toggling the checkbox, as well. These changes bring checkbox fields in compliance with the ARIA checkbox pattern documented here: https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/. - #8210: This one required a lot of changes in order to adapt to the ARIA combobox pattern documented here: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/. Specifically: - Menus needed to have a unique ID that's also exposed in order to link the combobox element to its menu when open. - ARIA's `activedescendant` proved very useful in ensuring that the current dropdown selection is correctly read when the combobox has focus but its menu isn't opened. - The default properties available for options (label and value) aren't very good for readout, so a custom ARIA property was added for much clearer option readouts. This is only demonstrated for the math arithmetic block for now. - The text element is normally hidden for ARIA but it's useful in conjunction with `activedescendant` to represent the current value selection. - Images have been handled here as well (partly as part of #8255) by leveraging their alt text for readouts. This actually seems to work quite well both for current value and selection. - #8213: Much of the improvements here come from the combobox (`FieldDropdown`) improvements explained above. However one additional bit was done to provide an explicit 'Variable ' readout for the purpose of clarity. This demonstrates some contextualization of the value of the field which may be a generally useful pattern to copy in other field contexts. - #8255: Image fields have been refined since they were redundantly specifying 'image' when an `image` ARIA role is already being used. Now only the alt text is supplied along with the role context. Note that images need special handling since they can sometimes be navigable (such as when they have click handlers). - #8211: Text input fields have had their labeling improved like all other fields, and the field's value is now exposed via its `text` element since this will show up as a `StaticText` node in the accessibility tree and automatically be read as part of the field's value. - #8212: This gets the same benefits as the previous point since those improvements were included for both text and number input. However, existing `valuemin` and `valuemax` ARIA properties have been removed. It seems these are really only useful when introducing a slider mechanism (see https://www.w3.org/WAI/ARIA/apg/patterns/slider/) and from testing seems to not really be utilized for the basic text input that `FieldNumber` currently uses. It may be the case that this is a better pattern to use in the future, but it's more likely that other custom fields could benefit from more specific patterns like slider rather than `FieldNumber` being changed in that way. - #8254 and part of #9304: Field labels have been completely removed from the accessibility node tree since they can never be navigated to (as #8254 explains all labels will be included as part of the block's ARIA label itself for readout parity with navigation options). Note that it doesn't cover external fields (such as those supplied in blockly-samples), nor does it fully set up the infrastructure work for those. Ultimately that work needs to happen as part of #9301. Beyond the role work above, this PR also introduces some fundamental work for #9301. Specifically: - It demonstrates how block definitions could be used to introduce accessibility label customizations (in this case for the options of the arithmetic operator block's drop-down field, plus the block itself). - It sets up some central label computation for all fields, though more thought is needed on whether this is sufficient for custom fields outside of core Blockly and on how to properly contextualize labels for field values. Core Blockly's fields are fairly simple for representing values which is why that aspect of #9301 didn't need to be solved in this PR. Note that the field labeling here is being used to improve all of the fields above, but also it tries to aggressively fall back to the _next best_ label to be used (though it's possible to run out of options which is why fields still need contextually-specific fallbacks). ### Reason for Changes Generally the initial approach for implementing labels is leveraging as specific ARIA roles as exist to directly represent the element. This PR is completing that work for all of core Blockly's built-in fields, and laying some of the groundwork for generalizing this support for custom fields. Having specific roles does potentially introduce inconsistencies across screen readers (though should improve consistency across sites for a single screen reader), and expectations for behaviors (like shortcuts) that may need to be ignored or only partially supported (#9313 is discussing this). ### Test Coverage Only manual testing has been completed since this is experimental work. Video demonstrating most of the changes: [Screen recording 2025-10-01 4.05.35 PM.webm](https://github.com/user-attachments/assets/c7961caa-eae0-4585-8fd9-87d7cbe65988) ### Documentation N/A -- Experimental work. ### Additional Information This has only been tested on ChromeVox. --- blocks/math.ts | 11 +-- core/css.ts | 2 +- core/field.ts | 17 +++++ core/field_checkbox.ts | 12 ++-- core/field_dropdown.ts | 112 ++++++++++++++++++++++------- core/field_image.ts | 8 ++- core/field_input.ts | 12 ++-- core/field_label.ts | 9 +-- core/field_number.ts | 10 +-- core/field_variable.ts | 8 ++- core/inject.ts | 1 + core/menu.ts | 8 ++- core/menuitem.ts | 4 ++ core/utils/aria.ts | 18 +++++ msg/json/en.json | 3 +- msg/json/qqq.json | 1 + msg/messages.js | 7 +- tests/mocha/field_dropdown_test.js | 6 +- tests/mocha/field_variable_test.js | 18 +++-- 19 files changed, 193 insertions(+), 74 deletions(-) diff --git a/blocks/math.ts b/blocks/math.ts index e5aef5fbb6e..b3f93321125 100644 --- a/blocks/math.ts +++ b/blocks/math.ts @@ -54,12 +54,13 @@ export const blocks = createBlockDefinitionsFromJsonArray([ { 'type': 'field_dropdown', 'name': 'OP', + 'ariaName': 'Arithmetic operation', 'options': [ - ['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD'], - ['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS'], - ['%{BKY_MATH_MULTIPLICATION_SYMBOL}', 'MULTIPLY'], - ['%{BKY_MATH_DIVISION_SYMBOL}', 'DIVIDE'], - ['%{BKY_MATH_POWER_SYMBOL}', 'POWER'], + ['%{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'], ], }, { diff --git a/core/css.ts b/core/css.ts index 8ecee5a7709..ae7bbee06d6 100644 --- a/core/css.ts +++ b/core/css.ts @@ -509,7 +509,7 @@ input[type=number] { outline: none; } -#blocklyAriaAnnounce { +.hiddenForAria { position: absolute; left: -9999px; width: 1px; diff --git a/core/field.ts b/core/field.ts index 605c1436fd5..79fd8c9f077 100644 --- a/core/field.ts +++ b/core/field.ts @@ -199,6 +199,8 @@ export abstract class Field /** The unique ID of this field. */ private id_: string | null = null; + private config: FieldConfig | null = null; + /** * @param value The initial value of the field. * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by @@ -251,6 +253,7 @@ export abstract class Field if (config.tooltip) { this.setTooltip(parsing.replaceMessageReferences(config.tooltip)); } + this.config = config; } /** @@ -272,6 +275,17 @@ export abstract class Field this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; } + getAriaName(): string | null { + return ( + this.config?.ariaName ?? + this.config?.name ?? + this.config?.type ?? + this.getSourceBlock()?.type ?? + this.name ?? + null + ); + } + /** * Get the renderer constant provider. * @@ -1418,7 +1432,10 @@ export abstract class Field * Extra configuration options for the base field. */ export interface FieldConfig { + type: string; + name?: string; tooltip?: string; + ariaName?: string; } /** diff --git a/core/field_checkbox.ts b/core/field_checkbox.ts index f7ab38ead56..df07168a7a2 100644 --- a/core/field_checkbox.ts +++ b/core/field_checkbox.ts @@ -113,13 +113,14 @@ export class FieldCheckbox extends Field { dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField'); textElement.style.display = this.value_ ? 'block' : 'none'; + this.recomputeAria(); + } + + private recomputeAria() { const element = this.getFocusableElement(); aria.setRole(element, aria.Role.CHECKBOX); - aria.setState( - element, - aria.State.LABEL, - this.name ? `Checkbox ${this.name}` : 'Checkbox', - ); + aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Checkbox'); + aria.setState(element, aria.State.CHECKED, !!this.value_); } override render_() { @@ -147,6 +148,7 @@ export class FieldCheckbox extends Field { /** Toggle the state of the checkbox on click. */ protected override showEditor_() { this.setValue(!this.value_); + this.recomputeAria(); } /** diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index f735da6c059..de6f6f5171c 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -28,6 +28,7 @@ import {MenuItem} from './menuitem.js'; import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; +import * as idGenerator from './utils/idgenerator.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; import * as utilsString from './utils/string.js'; @@ -198,12 +199,28 @@ export class FieldDropdown extends Field { dom.addClass(this.fieldGroup_, 'blocklyDropdownField'); } + this.recomputeAria(); + } + + private recomputeAria() { + if (!this.fieldGroup_) return; // There's no element to set currently. const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.LISTBOX); + 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, this.getAriaName() ?? 'Dropdown'); + + // Ensure the selected item has its correct label presented since it may be + // different than the actual text presented to the user. aria.setState( - element, + this.getTextElement(), aria.State.LABEL, - this.name ? `Item ${this.name}` : 'Item', + this.computeLabelForOption(this.selectedOption), ); } @@ -335,7 +352,11 @@ export class FieldDropdown extends Field { } return label; })(); - const menuItem = new MenuItem(content, value); + const menuItem = new MenuItem( + content, + value, + this.computeLabelForOption(option), + ); menuItem.setRole(aria.Role.OPTION); menuItem.setRightToLeft(block.RTL); menuItem.setCheckable(true); @@ -346,6 +367,24 @@ export class FieldDropdown extends Field { } menuItem.onAction(this.handleMenuActionEvent, this); } + + this.recomputeAria(); + } + + private computeLabelForOption(option: MenuOption): string { + if (option === FieldDropdown.SEPARATOR) { + return ''; // Separators don't need labels. + } else if (!Array.isArray(option)) { + return ''; // Certain dynamic options aren't iterable. TODO: Figure this out. It breaks when opening certain test toolbox categories in the advanced playground. + } + const [label, value, optionalAriaLabel] = option; + const altText = isImageProperties(label) ? label.alt : null; + return ( + altText ?? + optionalAriaLabel ?? + this.computeHumanReadableText(option) ?? + String(value) + ); } /** @@ -358,6 +397,7 @@ export class FieldDropdown extends Field { this.menu_ = null; this.selectedMenuItem = null; this.applyColour(); + this.recomputeAria(); } /** @@ -380,6 +420,11 @@ export class FieldDropdown extends Field { this.setValue(menuItem.getValue()); } + override setValue(newValue: AnyDuringMigration, fireChangeEvent = true) { + super.setValue(newValue, fireChangeEvent); + this.recomputeAria(); + } + /** * @returns True if the option list is generated by a function. * Otherwise false. @@ -532,14 +577,11 @@ export class FieldDropdown extends Field { if (!block) { throw new UnattachedFieldError(); } - this.imageElement!.style.display = ''; - this.imageElement!.setAttributeNS( - dom.XLINK_NS, - 'xlink:href', - imageJson.src, - ); - this.imageElement!.setAttribute('height', String(imageJson.height)); - this.imageElement!.setAttribute('width', String(imageJson.width)); + const imageElement = this.imageElement!; + imageElement.style.display = ''; + imageElement.setAttributeNS(dom.XLINK_NS, 'xlink:href', imageJson.src); + imageElement.setAttribute('height', String(imageJson.height)); + imageElement.setAttribute('width', String(imageJson.width)); const imageHeight = Number(imageJson.height); const imageWidth = Number(imageJson.width); @@ -567,15 +609,24 @@ export class FieldDropdown extends Field { let arrowX = 0; if (block.RTL) { const imageX = xPadding + arrowWidth; - this.imageElement!.setAttribute('x', `${imageX}`); + imageElement.setAttribute('x', `${imageX}`); } else { arrowX = imageWidth + arrowWidth; this.getTextElement().setAttribute('text-anchor', 'end'); - this.imageElement!.setAttribute('x', `${xPadding}`); + imageElement.setAttribute('x', `${xPadding}`); } - this.imageElement!.setAttribute('y', String(height / 2 - imageHeight / 2)); + imageElement.setAttribute('y', String(height / 2 - imageHeight / 2)); this.positionTextElement_(arrowX + xPadding, imageWidth + arrowWidth); + + if (imageElement.id === '') { + imageElement.id = idGenerator.getNextUniqueId(); + const element = this.getFocusableElement(); + aria.setState(element, aria.State.ACTIVEDESCENDANT, imageElement.id); + } + + aria.setRole(imageElement, aria.Role.IMAGE); + aria.setState(imageElement, aria.State.LABEL, imageJson.alt); } /** Renders the selected option, which must be text. */ @@ -585,6 +636,14 @@ export class FieldDropdown extends Field { const textElement = this.getTextElement(); dom.addClass(textElement, 'blocklyDropdownText'); textElement.setAttribute('text-anchor', 'start'); + // The field's text should be visible to readers since it will be read out + // as static text as part of the combobox (per the ARIA combobox pattern). + if (textElement.id === '') { + textElement.id = idGenerator.getNextUniqueId(); + const element = this.getFocusableElement(); + aria.setState(element, aria.State.ACTIVEDESCENDANT, textElement.id); + } + aria.setState(textElement, aria.State.HIDDEN, false); // Height and width include the border rect. const hasBorder = !!this.borderRect_; @@ -654,7 +713,11 @@ export class FieldDropdown extends Field { if (!this.selectedOption) { return null; } - const option = this.selectedOption[0]; + return this.computeHumanReadableText(this.selectedOption); + } + + private computeHumanReadableText(menuOption: MenuOption): string | null { + const option = menuOption[0]; if (isImageProperties(option)) { return option.alt; } else if ( @@ -689,7 +752,7 @@ export class FieldDropdown extends Field { throw new Error( 'options are required for the dropdown field. The ' + 'options property must be assigned an array of ' + - '[humanReadableValue, languageNeutralValue] tuples.', + '[humanReadableValue, languageNeutralValue, opt_ariaLabel] tuples.', ); } // `this` might be a subclass of FieldDropdown if that class doesn't @@ -713,9 +776,9 @@ export class FieldDropdown extends Field { return option; } - const [label, value] = option; + const [label, value, opt_ariaLabel] = option; if (typeof label === 'string') { - return [parsing.replaceMessageReferences(label), value]; + return [parsing.replaceMessageReferences(label), value, opt_ariaLabel]; } hasNonTextContent = true; @@ -724,14 +787,14 @@ export class FieldDropdown extends Field { const imageLabel = isImageProperties(label) ? {...label, alt: parsing.replaceMessageReferences(label.alt)} : label; - return [imageLabel, value]; + return [imageLabel, value, opt_ariaLabel]; }); 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); @@ -770,13 +833,14 @@ 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]) => [ + return options.map(([text, value, opt_ariaLabel]) => [ text.substring(prefixLength, text.length - suffixLength), value, + opt_ariaLabel, ]); } @@ -868,7 +932,7 @@ export interface ImageProperties { * the language-neutral value. */ export type MenuOption = - | [string | ImageProperties | HTMLElement, string] + | [string | ImageProperties | HTMLElement, string, string?] | 'separator'; /** diff --git a/core/field_image.ts b/core/field_image.ts index e6ac13e0810..ae66eae3d4d 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -155,16 +155,18 @@ export class FieldImage extends Field { dom.addClass(this.fieldGroup_, 'blocklyImageField'); } + const element = this.getFocusableElement(); if (this.clickHandler) { this.imageElement.style.cursor = 'pointer'; + aria.setRole(element, aria.Role.BUTTON); + } else { + aria.setRole(element, aria.Role.IMAGE); } - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.IMAGE); aria.setState( element, aria.State.LABEL, - this.name ? `Image ${this.name}` : 'Image', + this.altText ?? this.getAriaName(), ); } diff --git a/core/field_input.ts b/core/field_input.ts index 3cca1dcc7c6..216dad11345 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -167,6 +167,8 @@ export abstract class FieldInput extends Field< const block = this.getSourceBlock(); if (!block) throw new UnattachedFieldError(); super.initView(); + if (!this.textElement_) + throw new Error('Initialization failed for FieldInput'); if (this.isFullBlockField()) { this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot(); @@ -176,13 +178,13 @@ export abstract class FieldInput extends Field< dom.addClass(this.fieldGroup_, 'blocklyInputField'); } + // Showing the text-based value with the input's textbox ensures that the + // input's value is correctly read out by screen readers with its role. + aria.setState(this.textElement_, aria.State.HIDDEN, false); + const element = this.getFocusableElement(); aria.setRole(element, aria.Role.TEXTBOX); - aria.setState( - element, - aria.State.LABEL, - this.name ? `Text ${this.name}` : 'Text', - ); + aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Text'); } override isFullBlockField(): boolean { diff --git a/core/field_label.ts b/core/field_label.ts index 901c21bd000..d89e397f9c4 100644 --- a/core/field_label.ts +++ b/core/field_label.ts @@ -79,11 +79,7 @@ export class FieldLabel extends Field { dom.addClass(this.fieldGroup_, 'blocklyLabelField'); } - this.recomputeAriaLabel(); - } - - private recomputeAriaLabel() { - aria.setState(this.getFocusableElement(), aria.State.LABEL, this.getText()); + aria.setState(this.getFocusableElement(), aria.State.HIDDEN, true); } /** @@ -120,9 +116,6 @@ export class FieldLabel extends Field { override setValue(newValue: any, fireChangeEvent?: boolean): void { super.setValue(newValue, fireChangeEvent); - if (this.fieldGroup_) { - this.recomputeAriaLabel(); - } } /** diff --git a/core/field_number.ts b/core/field_number.ts index 7e36591753e..b1d58e86ee9 100644 --- a/core/field_number.ts +++ b/core/field_number.ts @@ -18,9 +18,13 @@ import { FieldInputValidator, } from './field_input.js'; import * as fieldRegistry from './field_registry.js'; -import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; +// TODO: Figure out how to either design this to be a 'number' input with proper +// 'valuemin' and 'valuemax' ARIA properties, build it so that subtypes can do +// this properly, or consider a separate field type altogether that handles that +// case properly. See: https://github.com/google/blockly/pull/9384#discussion_r2395601092. + /** * Class for an editable number field. */ @@ -296,14 +300,11 @@ export class FieldNumber extends FieldInput { protected override widgetCreate_(): HTMLInputElement { const htmlInput = super.widgetCreate_() as HTMLInputElement; - // 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; } @@ -313,7 +314,6 @@ export class FieldNumber extends FieldInput { * * @override */ - public override initView() { super.initView(); if (this.fieldGroup_) { diff --git a/core/field_variable.ts b/core/field_variable.ts index aa4fdfe310f..ede3d751488 100644 --- a/core/field_variable.ts +++ b/core/field_variable.ts @@ -596,22 +596,28 @@ export class FieldVariable extends FieldDropdown { } variableModelList.sort(Variables.compareByName); - const options: [string, string][] = []; + const options: [string, string, string?][] = []; for (let i = 0; i < variableModelList.length; i++) { // Set the UUID as the internal representation of the variable. options[i] = [ variableModelList[i].getName(), variableModelList[i].getId(), + Msg['ARIA_LABEL_FOR_VARIABLE_NAME'].replace( + '%1', + variableModelList[i].getName(), + ), ]; } options.push([ Msg['RENAME_VARIABLE'], internalConstants.RENAME_VARIABLE_ID, + Msg['RENAME_VARIABLE'], ]); if (Msg['DELETE_VARIABLE']) { options.push([ Msg['DELETE_VARIABLE'].replace('%1', name), internalConstants.DELETE_VARIABLE_ID, + Msg['DELETE_VARIABLE'].replace('%1', name), ]); } diff --git a/core/inject.ts b/core/inject.ts index eeeddb282c3..3df73c41203 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -85,6 +85,7 @@ export function inject( // See: https://stackoverflow.com/a/48590836 for a reference. const ariaAnnouncementSpan = document.createElement('span'); ariaAnnouncementSpan.id = 'blocklyAriaAnnounce'; + dom.addClass(ariaAnnouncementSpan, 'hiddenForAria'); aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'polite'); subContainer.appendChild(ariaAnnouncementSpan); diff --git a/core/menu.ts b/core/menu.ts index 13fd0866f49..29e5f40aa0e 100644 --- a/core/menu.ts +++ b/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,8 +63,12 @@ export class Menu { /** ARIA name for this menu. */ private roleName: aria.Role | null = null; + id: string; + /** Constructs a new Menu instance. */ - constructor() {} + constructor() { + this.id = idGenerator.getNextUniqueId(); + } /** * Add a new menu item to the bottom of this menu. @@ -86,6 +91,7 @@ export class Menu { const element = document.createElement('div'); element.className = 'blocklyMenu'; element.tabIndex = 0; + element.id = this.id; if (this.roleName) { aria.setRole(element, this.roleName); } diff --git a/core/menuitem.ts b/core/menuitem.ts index b3ae33c5c12..2a36620ee72 100644 --- a/core/menuitem.ts +++ b/core/menuitem.ts @@ -52,6 +52,7 @@ export class MenuItem { constructor( private readonly content: string | HTMLElement, private readonly opt_value?: string, + private readonly opt_ariaLabel?: string, ) {} /** @@ -98,6 +99,9 @@ export class MenuItem { (this.checkable && this.checked) || false, ); aria.setState(element, aria.State.DISABLED, !this.enabled); + if (this.opt_ariaLabel) { + aria.setState(element, aria.State.LABEL, this.opt_ariaLabel); + } return element; } diff --git a/core/utils/aria.ts b/core/utils/aria.ts index aa9ec3f29d6..a1f7d83b8e9 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -51,6 +51,8 @@ export enum Role { BUTTON = 'button', CHECKBOX = 'checkbox', TEXTBOX = 'textbox', + COMBOBOX = 'combobox', + SPINBUTTON = 'spinbutton', } /** @@ -99,6 +101,8 @@ export enum State { // ARIA property for slider minimum value. Value: number. VALUEMIN = 'valuemin', + VALUENOW = 'valuenow', + // ARIA property for live region chattiness. // Value: one of {polite, assertive, off}. LIVE = 'live', @@ -109,6 +113,9 @@ export enum State { ROLEDESCRIPTION = 'roledescription', OWNS = 'owns', + HASPOPUP = 'haspopup', + CONTROLS = 'controls', + CHECKED = 'checked', } /** @@ -165,6 +172,17 @@ export function setState( element.setAttribute(attrStateName, `${value}`); } +/** + * Clears the specified ARIA state by removing any related attributes from the + * specified element that have been set using setState(). + * + * @param element The element whose ARIA state may be changed. + * @param stateName The state to clear from the provided element. + */ +export function clearState(element: Element, stateName: State) { + element.removeAttribute(ARIA_PREFIX + stateName); +} + /** * Returns a string representation of the specified state for the specified * element, or null if it's not defined or specified. diff --git a/msg/json/en.json b/msg/json/en.json index a589674d206..f9840a8f44b 100644 --- a/msg/json/en.json +++ b/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2025-09-22 11:22:54.733649", + "lastupdated": "2025-09-23 23:27:37.312782", "locale": "en", "messagedocumentation" : "qqq" }, @@ -32,6 +32,7 @@ "CHANGE_VALUE_TITLE": "Change value:", "RENAME_VARIABLE": "Rename variable...", "RENAME_VARIABLE_TITLE": "Rename all '%1' variables to:", + "ARIA_LABEL_FOR_VARIABLE_NAME": "Variable '%1'", "NEW_VARIABLE": "Create variable...", "NEW_STRING_VARIABLE": "Create string variable...", "NEW_NUMBER_VARIABLE": "Create number variable...", diff --git a/msg/json/qqq.json b/msg/json/qqq.json index 0f69fdda7e1..f6980bd422d 100644 --- a/msg/json/qqq.json +++ b/msg/json/qqq.json @@ -39,6 +39,7 @@ "CHANGE_VALUE_TITLE": "prompt - This message is seen on mobile devices and the Opera browser. With most browsers, users can edit numeric values in blocks by just clicking and typing. Opera does not allow this and mobile browsers may have issues with in-line textareas. So we prompt users with this message (usually a popup) to change a value.", "RENAME_VARIABLE": "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/google/blockly/wiki/Variables#dropdown-menu https://github.com/google/blockly/wiki/Variables#dropdown-menu].", "RENAME_VARIABLE_TITLE": "prompt - Prompts the user to enter the new name for the selected variable. See [https://github.com/google/blockly/wiki/Variables#dropdown-menu https://github.com/google/blockly/wiki/Variables#dropdown-menu].\n\nParameters:\n* %1 - the name of the variable to be renamed.", + "ARIA_LABEL_FOR_VARIABLE_NAME": "dropdown choice - Provides screen reader users with a label that contextualizes a variable as an actual variable with its name.", "NEW_VARIABLE": "button text - Text on the button used to launch the variable creation dialogue.", "NEW_STRING_VARIABLE": "button text - Text on the button used to launch the variable creation dialogue.", "NEW_NUMBER_VARIABLE": "button text - Text on the button used to launch the variable creation dialogue.", diff --git a/msg/messages.js b/msg/messages.js index 83c8bda0e29..575e97ea59f 100644 --- a/msg/messages.js +++ b/msg/messages.js @@ -9,13 +9,13 @@ * * After modifying this file, run: * - * npm run generate:langfiles + * npm run messages * * to regenerate json/{en,qqq,constants,synonyms}.json. * * To convert all of the json files to .js files, run: * - * npm run build:langfiles + * npm run langfiles */ 'use strict'; @@ -146,6 +146,9 @@ Blockly.Msg.RENAME_VARIABLE = 'Rename variable...'; /** @type {string} */ /// prompt - Prompts the user to enter the new name for the selected variable. See [https://github.com/google/blockly/wiki/Variables#dropdown-menu https://github.com/google/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:'; +/** @type {string} */ +/// dropdown choice - Provides screen reader users with a label that contextualizes a variable as an actual variable with its name. +Blockly.Msg.ARIA_LABEL_FOR_VARIABLE_NAME = 'Variable "%1"'; // Variable creation /** @type {string} */ diff --git a/tests/mocha/field_dropdown_test.js b/tests/mocha/field_dropdown_test.js index a1731e81281..81964c35e64 100644 --- a/tests/mocha/field_dropdown_test.js +++ b/tests/mocha/field_dropdown_test.js @@ -230,9 +230,9 @@ suite('Dropdown Fields', function () { assert.deepEqual(this.field.prefixField, 'a'); assert.deepEqual(this.field.suffixField, 'b'); assert.deepEqual(this.field.getOptions(), [ - ['d', 'D'], - ['e', 'E'], - ['f', 'F'], + ['d', 'D', undefined], + ['e', 'E', undefined], + ['f', 'F', undefined], ]); }); test('With an empty array of options throws', function () { diff --git a/tests/mocha/field_variable_test.js b/tests/mocha/field_variable_test.js index 58a20977521..4dfa328d1bd 100644 --- a/tests/mocha/field_variable_test.js +++ b/tests/mocha/field_variable_test.js @@ -201,9 +201,7 @@ suite('Variable Fields', function () { Blockly.FieldVariable.dropdownCreate.call(fieldVariable); // Expect variable options, a rename option, and a delete option. assert.lengthOf(dropdownOptions, expectedVarOptions.length + 2); - for (let i = 0, option; (option = expectedVarOptions[i]); i++) { - assert.deepEqual(dropdownOptions[i], option); - } + assert.deepEqual(dropdownOptions.slice(0, -2), expectedVarOptions); assert.include(dropdownOptions[dropdownOptions.length - 2][0], 'Rename'); assert.include(dropdownOptions[dropdownOptions.length - 1][0], 'Delete'); @@ -217,8 +215,8 @@ suite('Variable Fields', function () { new Blockly.FieldVariable('name2'), ); assertDropdownContents(fieldVariable, [ - ['name1', 'id1'], - ['name2', 'id2'], + ['name1', 'id1', "Variable 'name1'"], + ['name2', 'id2', "Variable 'name2'"], ]); }); test('Contains variables created after field', function () { @@ -230,8 +228,8 @@ suite('Variable Fields', function () { // Expect that variables created after field creation will show up too. this.workspace.createVariable('name2', '', 'id2'); assertDropdownContents(fieldVariable, [ - ['name1', 'id1'], - ['name2', 'id2'], + ['name1', 'id1', "Variable 'name1'"], + ['name2', 'id2', "Variable 'name2'"], ]); }); test('Contains variables created before and after field', function () { @@ -245,9 +243,9 @@ suite('Variable Fields', function () { // Expect that variables created after field creation will show up too. this.workspace.createVariable('name3', '', 'id3'); assertDropdownContents(fieldVariable, [ - ['name1', 'id1'], - ['name2', 'id2'], - ['name3', 'id3'], + ['name1', 'id1', "Variable 'name1'"], + ['name2', 'id2', "Variable 'name2'"], + ['name3', 'id3', "Variable 'name3'"], ]); }); }); From bf576d5bc3e6515f21417b471119de7566a83296 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Fri, 3 Oct 2025 10:54:14 -0700 Subject: [PATCH 13/51] fix: improve aria markup for toolboxes (#9398) * fix: set activedescendant correctly on toolbox * fix: dont manually set posinset for toolbox categories * fix: dont set activedescendant on toolbox at all --- core/toolbox/collapsible_category.ts | 12 --------- core/toolbox/toolbox.ts | 38 ---------------------------- 2 files changed, 50 deletions(-) diff --git a/core/toolbox/collapsible_category.ts b/core/toolbox/collapsible_category.ts index 7f8d8a915d8..5cfe7e84f64 100644 --- a/core/toolbox/collapsible_category.ts +++ b/core/toolbox/collapsible_category.ts @@ -137,18 +137,6 @@ export class CollapsibleToolboxCategory aria.setState(this.htmlDiv_ as HTMLDivElement, aria.State.EXPANDED, false); aria.setRole(this.htmlDiv_!, aria.Role.TREEITEM); - // Ensure this group has properly set children. - const selectableChildren = - this.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null; - const focusableChildIds = selectableChildren.map( - (selectable) => selectable.getFocusableElement().id, - ); - aria.setState( - this.htmlDiv_!, - aria.State.OWNS, - [...new Set(focusableChildIds)].join(' '), - ); - return this.htmlDiv_!; } diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index d006074d2a4..2ede1bad1cb 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -369,7 +369,6 @@ export class Toolbox this.renderContents_(toolboxDef['contents']); this.position(); this.handleToolboxItemResize(); - this.recomputeAriaOwners(); } /** @@ -446,7 +445,6 @@ export class Toolbox this.addToolboxItem_(child); } } - this.recomputeAriaOwners(); } /** @@ -881,11 +879,6 @@ export class Toolbox this.selectedItem_ = null; this.previouslySelectedItem_ = item; item.setSelected(false); - aria.setState( - this.contentsDiv_ as Element, - aria.State.ACTIVEDESCENDANT, - '', - ); } /** @@ -901,11 +894,6 @@ export class Toolbox this.selectedItem_ = newItem; this.previouslySelectedItem_ = oldItem; newItem.setSelected(true); - aria.setState( - this.contentsDiv_ as Element, - aria.State.ACTIVEDESCENDANT, - newItem.getId(), - ); } /** @@ -1155,32 +1143,6 @@ export class Toolbox this.autoHide(false); } } - - /** - * Recomputes ARIA tree ownership relationships for all of this toolbox's - * categories and items. - * - * This should only be done when the toolbox's contents have changed. - */ - recomputeAriaOwners() { - const focusable = this.getFocusableElement(); - const selectableChildren = - this.getToolboxItems().filter((item) => item.isSelectable()) ?? null; - const focusableChildElems = selectableChildren.map((selectable) => - selectable.getFocusableElement(), - ); - const focusableChildIds = focusableChildElems.map((elem) => elem.id); - aria.setState( - focusable, - aria.State.OWNS, - [...new Set(focusableChildIds)].join(' '), - ); - // Ensure children have the correct position set. - // TODO: Fix collapsible subcategories. Their groups aren't set up correctly yet, and they aren't getting a correct accounting in top-level toolbox tree. - focusableChildElems.forEach((elem, index) => - aria.setState(elem, aria.State.POSINSET, index + 1), - ); - } } /** CSS for Toolbox. See css.js for use. */ From 0eec0e0cd66e3d250e6a398664ac7af4385fe3c0 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 3 Oct 2025 10:55:21 -0700 Subject: [PATCH 14/51] fix: Fix exception thrown when `FieldDropdown` subclasses don't have a textual label. (#9401) --- core/field_dropdown.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index de6f6f5171c..7b6e8d5e821 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -217,11 +217,13 @@ export class FieldDropdown extends Field { // Ensure the selected item has its correct label presented since it may be // different than the actual text presented to the user. - aria.setState( - this.getTextElement(), - aria.State.LABEL, - this.computeLabelForOption(this.selectedOption), - ); + if (this.textElement_) { + aria.setState( + this.textElement_, + aria.State.LABEL, + this.computeLabelForOption(this.selectedOption), + ); + } } /** From b74ebe200f9d25977e8a1cb63f5d654b2126666e Mon Sep 17 00:00:00 2001 From: Robert Knight <95928279+microbit-robert@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:44:01 +0100 Subject: [PATCH 15/51] Introduce better block labeling for screen readers (#9357) Read value-inputs and fields in place and recursively. Announce block shape, number of inputs and number of children where appropriate. Co-authored-by: Matt Hillsdon --- core/block_svg.ts | 131 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 110 insertions(+), 21 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 69303309031..c815228ec28 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -218,12 +218,11 @@ export class BlockSvg // The page-wide unique ID of this Block used for focusing. svgPath.id = idGenerator.getNextUniqueId(); - aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); - aria.setRole(svgPath, aria.Role.TREEITEM); svgPath.tabIndex = -1; this.currentConnectionCandidate = null; this.doInit_(); + this.computeAriaRole(); } private recomputeAriaLabel() { @@ -235,29 +234,67 @@ export class BlockSvg } private computeAriaLabel(): string { - // Guess the block's aria label based on its field labels. - if (this.isShadow() || this.isSimpleReporter()) { - // TODO: Shadows may have more than one field. - // Shadow blocks are best represented directly by their field since they - // effectively operate like a field does for keyboard navigation purposes. - const field = Array.from(this.getFields())[0]; - try { - return ( - aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? - 'Unknown?' - ); - } catch { - return 'Unknown?'; + const {blockSummary, inputCount} = buildBlockSummary(this); + const inputSummary = inputCount + ? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}` + : ''; + + let currentBlock: Block | null = null; + let nestedStatementBlockCount = 0; + // This won't work well for if/else blocks. + this.inputList.forEach((input) => { + if ( + input.connection && + input.connection.type === ConnectionType.NEXT_STATEMENT + ) { + currentBlock = input.connection.targetBlock(); + } + }); + // The type is poorly inferred here. + while (currentBlock as Block | null) { + nestedStatementBlockCount++; + // The type is poorly inferred here. + // If currentBlock is null, we can't enter this while loop... + currentBlock = currentBlock!.getNextBlock(); + } + + let blockTypeText = 'block'; + if (this.isShadow()) { + blockTypeText = 'input block'; + } else if (this.outputConnection) { + blockTypeText = 'replacable block'; + } else if (this.statementInputCount) { + blockTypeText = 'C-shaped block'; + } + + let additionalInfo = blockTypeText; + if (inputSummary && !nestedStatementBlockCount) { + additionalInfo = `${additionalInfo} with ${inputSummary}`; + } else if (nestedStatementBlockCount) { + const childBlockSummary = `${nestedStatementBlockCount} child ${nestedStatementBlockCount > 1 ? 'blocks' : 'block'}`; + if (inputSummary) { + additionalInfo = `${additionalInfo} with ${inputSummary} and ${childBlockSummary}`; + } else { + additionalInfo = `${additionalInfo} with ${childBlockSummary}`; } } - const fieldLabels = []; - for (const field of this.getFields()) { - if (field instanceof FieldLabel) { - fieldLabels.push(field.getText()); - } + return blockSummary + ', ' + additionalInfo; + } + + private computeAriaRole() { + if (this.isSimpleReporter()) { + aria.setRole(this.pathObject.svgPath, aria.Role.BUTTON); + } else { + // This isn't read out by VoiceOver and it will read in the wrong place + // as a duplicate in ChromeVox due to the other changes in this branch. + // aria.setState( + // this.pathObject.svgPath, + // aria.State.ROLEDESCRIPTION, + // 'block', + // ); + aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM); } - return fieldLabels.join(' '); } collectSiblingBlocks(surroundParent: BlockSvg | null): BlockSvg[] { @@ -1724,6 +1761,8 @@ export class BlockSvg * settings. */ render() { + this.recomputeAriaLabel(); + this.queueRender(); renderManagement.triggerQueuedRenders(); } @@ -1735,6 +1774,8 @@ export class BlockSvg * @internal */ renderEfficiently() { + this.recomputeAriaLabel(); + dom.startTextWidthCache(); if (this.isCollapsed()) { @@ -1991,3 +2032,51 @@ export class BlockSvg } } } + +interface BlockSummary { + blockSummary: string; + inputCount: number; +} + +function buildBlockSummary(block: BlockSvg): BlockSummary { + let inputCount = 0; + function recursiveInputSummary( + block: BlockSvg, + isNestedInput: boolean = false, + ): string { + return block.inputList + .flatMap((input) => { + const fields = input.fieldRow.map((field) => { + // If the block is a full block field, we only want to know if it's an + // editable field if we're not directly on it. + if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) { + inputCount++; + } + return [field.getText() ?? field.getValue()]; + }); + if ( + input.connection && + input.connection.type === ConnectionType.INPUT_VALUE + ) { + if (!isNestedInput) { + inputCount++; + } + const targetBlock = input.connection.targetBlock(); + if (targetBlock) { + return [ + ...fields, + recursiveInputSummary(targetBlock as BlockSvg, true), + ]; + } + } + return fields; + }) + .join(' '); + } + + const blockSummary = recursiveInputSummary(block); + return { + blockSummary, + inputCount, + }; +} From 9d85f9b64aac0fd69cc5a5ec30d6492a2762aea2 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 7 Oct 2025 14:34:25 -0700 Subject: [PATCH 16/51] fix: Use `button` as the `aria-role` for icons. (#9408) --- core/icons/icon.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/icons/icon.ts b/core/icons/icon.ts index 7612f881e0f..259b3dcddbd 100644 --- a/core/icons/icon.ts +++ b/core/icons/icon.ts @@ -74,7 +74,7 @@ export abstract class Icon implements IIcon { (this.svgRoot as any).tooltip = this; tooltip.bindMouseEvents(this.svgRoot); - aria.setRole(this.svgRoot, aria.Role.FIGURE); + aria.setRole(this.svgRoot, aria.Role.BUTTON); aria.setState(this.svgRoot, aria.State.LABEL, 'Icon'); } From 40aa0d332896c7fee2721b54ef99c44f6fe799b3 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 13 Oct 2025 12:18:38 -0700 Subject: [PATCH 17/51] fix: Improve narration and navigation of C-shaped blocks. (#9416) * fix: Improve narration and navigation of C-shaped blocks. * chore: Satisfy the linter. * chore: Refactor and comment `getBlockNavigationCandidates()`. * refactor: Reduce code duplication in `LineCursor`. * fix: Add missing case when labeling connections. --- core/block_svg.ts | 17 ++- core/inputs/input.ts | 15 ++ core/keyboard_nav/block_navigation_policy.ts | 34 ++++- core/keyboard_nav/line_cursor.ts | 139 ++++++++++++++++--- core/rendered_connection.ts | 28 +++- tests/mocha/cursor_test.js | 18 ++- tests/mocha/navigation_test.js | 15 +- tsconfig.json | 1 + 8 files changed, 225 insertions(+), 42 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index c815228ec28..f5327ceff0c 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -39,6 +39,7 @@ import {IconType} from './icons/icon_types.js'; import {MutatorIcon} from './icons/mutator_icon.js'; import {WarningIcon} from './icons/warning_icon.js'; import type {Input} from './inputs/input.js'; +import {inputTypes} from './inputs/input_types.js'; import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {ICopyable} from './interfaces/i_copyable.js'; @@ -267,6 +268,20 @@ export class BlockSvg blockTypeText = 'C-shaped block'; } + let prefix = ''; + const parentInput = ( + this.previousConnection ?? this.outputConnection + )?.targetConnection?.getParentInput(); + if (parentInput && parentInput.type === inputTypes.STATEMENT) { + prefix = `Begin ${parentInput.getFieldRowLabel()}, `; + } else if ( + parentInput && + parentInput.type === inputTypes.VALUE && + this.getParent()?.statementInputCount + ) { + prefix = `${parentInput.getFieldRowLabel()} `; + } + let additionalInfo = blockTypeText; if (inputSummary && !nestedStatementBlockCount) { additionalInfo = `${additionalInfo} with ${inputSummary}`; @@ -279,7 +294,7 @@ export class BlockSvg } } - return blockSummary + ', ' + additionalInfo; + return prefix + blockSummary + ', ' + additionalInfo; } private computeAriaRole() { diff --git a/core/inputs/input.ts b/core/inputs/input.ts index f8783aea35f..c6f75712a3b 100644 --- a/core/inputs/input.ts +++ b/core/inputs/input.ts @@ -303,6 +303,21 @@ export class Input { } } + /** + * Returns a label for this input's row on its parent block. + * + * Generally this consists of the labels/values of the preceding fields, and + * is intended for accessibility descriptions. + * + * @internal + * @returns A description of this input's row on its parent block. + */ + getFieldRowLabel() { + return this.fieldRow.reduce((label, field) => { + return `${label} ${field.EDITABLE ? field.getAriaName() : field.getValue()}`; + }, ''); + } + /** * Constructs a connection based on the type of this input's source block. * Properly handles constructing headless connections for headless blocks diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 9f56b538455..93fc93dd199 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -124,24 +124,48 @@ function getBlockNavigationCandidates( for (const input of block.inputList) { if (!input.isVisible()) continue; + candidates.push(...input.fieldRow); - if (input.connection?.targetBlock()) { - const connectedBlock = input.connection.targetBlock() as BlockSvg; - if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) { + + const connection = input.connection as RenderedConnection | null; + if (!connection) continue; + + const connectedBlock = connection.targetBlock(); + if (connectedBlock) { + if (connection.type === ConnectionType.NEXT_STATEMENT && !forward) { const lastStackBlock = connectedBlock .lastConnectionInStack(false) ?.getSourceBlock(); if (lastStackBlock) { + // When navigating backward, the last block in a stack in a statement + // input is navigable. candidates.push(lastStackBlock); } } else { + // When navigating forward, a child block connected to a statement + // input is navigable. candidates.push(connectedBlock); } - } else if (input.connection?.type === ConnectionType.INPUT_VALUE) { - candidates.push(input.connection as RenderedConnection); + } else if ( + connection.type === ConnectionType.INPUT_VALUE || + connection.type === ConnectionType.NEXT_STATEMENT + ) { + // Empty input or statement connections are navigable. + candidates.push(connection); } } + if ( + block.nextConnection && + !block.nextConnection.targetBlock() && + (block.lastConnectionInStack(true) !== block.nextConnection || + !!block.getSurroundParent()) + ) { + // The empty next connection on the last block in a stack inside of a + // statement input is navigable. + candidates.push(block.nextConnection); + } + return candidates; } diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 30770e47d2d..1ca61041898 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -15,12 +15,24 @@ import {BlockSvg} from '../block_svg.js'; import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; +import {ConnectionType} from '../connection_type.js'; import {getFocusManager} from '../focus_manager.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import * as registry from '../registry.js'; +import {RenderedConnection} from '../rendered_connection.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; import {Marker} from './marker.js'; +/** + * Representation of the direction of travel within a navigation context. + */ +export enum NavigationDirection { + NEXT, + PREVIOUS, + IN, + OUT, +} + /** * Class for a line cursor. */ @@ -51,13 +63,7 @@ export class LineCursor extends Marker { } const newNode = this.getNextNode( curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, + this.getValidationFunction(NavigationDirection.NEXT), true, ); @@ -80,7 +86,11 @@ export class LineCursor extends Marker { return null; } - const newNode = this.getNextNode(curNode, () => true, true); + const newNode = this.getNextNode( + curNode, + this.getValidationFunction(NavigationDirection.IN), + true, + ); if (newNode) { this.setCurNode(newNode); @@ -101,13 +111,7 @@ export class LineCursor extends Marker { } const newNode = this.getPreviousNode( curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, + this.getValidationFunction(NavigationDirection.PREVIOUS), true, ); @@ -130,7 +134,11 @@ export class LineCursor extends Marker { return null; } - const newNode = this.getPreviousNode(curNode, () => true, true); + const newNode = this.getPreviousNode( + curNode, + this.getValidationFunction(NavigationDirection.OUT), + true, + ); if (newNode) { this.setCurNode(newNode); @@ -147,15 +155,14 @@ export class LineCursor extends Marker { atEndOfLine(): boolean { const curNode = this.getCurNode(); if (!curNode) return false; - const inNode = this.getNextNode(curNode, () => true, true); + const inNode = this.getNextNode( + curNode, + this.getValidationFunction(NavigationDirection.IN), + true, + ); const nextNode = this.getNextNode( curNode, - (candidate: IFocusableNode | null) => { - return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() - ); - }, + this.getValidationFunction(NavigationDirection.NEXT), true, ); @@ -298,6 +305,92 @@ export class LineCursor extends Marker { return this.getRightMostChild(newNode, stopIfFound); } + /** + * Returns a function that will be used to determine whether a candidate for + * navigation is valid. + * + * @param direction The direction in which the user is navigating. + * @returns A function that takes a proposed navigation candidate and returns + * true if navigation should be allowed to proceed to it, or false to find + * a different candidate. + */ + getValidationFunction( + direction: NavigationDirection, + ): (node: IFocusableNode | null) => boolean { + switch (direction) { + case NavigationDirection.IN: + case NavigationDirection.OUT: + return () => true; + case NavigationDirection.NEXT: + case NavigationDirection.PREVIOUS: + return (candidate: IFocusableNode | null) => { + if ( + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment || + (candidate instanceof RenderedConnection && + (candidate.type === ConnectionType.NEXT_STATEMENT || + (candidate.type === ConnectionType.INPUT_VALUE && + candidate.getSourceBlock().statementInputCount && + candidate.getSourceBlock().inputList[0] !== + candidate.getParentInput()))) + ) { + return true; + } + + const current = this.getSourceBlockFromNode(this.getCurNode()); + if (candidate instanceof BlockSvg && current instanceof BlockSvg) { + // If the candidate's parent uses inline inputs, disallow the + // candidate; it follows that it must be on the same row as its + // parent. + if (candidate.outputConnection?.targetBlock()?.getInputsInline()) { + return false; + } + + const candidateParents = this.getParents(candidate); + // If the candidate block is an (in)direct child of the current + // block, disallow it; it cannot be on a different row than the + // current block. + if ( + current === this.getCurNode() && + candidateParents.has(current) + ) { + return false; + } + + const currentParents = this.getParents(current); + + const sharedParents = currentParents.intersection(candidateParents); + // Allow the candidate if it and the current block have no parents + // in common, or if they have a shared parent with external inputs. + const result = + !sharedParents.size || + sharedParents.values().some((block) => !block.getInputsInline()); + return result; + } + + return false; + }; + } + } + + /** + * Returns a set of all of the parent blocks of the given block. + * + * @param block The block to retrieve the parents of. + * @returns A set of the parents of the given block. + */ + private getParents(block: BlockSvg): Set { + const parents = new Set(); + let parent = block.getParent(); + while (parent) { + parents.add(parent); + parent = parent.getParent(); + } + + return parents; + } + /** * Prepare for the deletion of a block by making a list of nodes we * could move the cursor to afterwards and save it to diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index ecfdfc3980b..bbf32006bc8 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -20,6 +20,7 @@ import {ConnectionType} from './connection_type.js'; import * as ContextMenu from './contextmenu.js'; import {ContextMenuRegistry} from './contextmenu_registry.js'; import * as eventUtils from './events/utils.js'; +import {inputTypes} from './inputs/input_types.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; @@ -334,7 +335,32 @@ export class RenderedConnection if (highlightSvg) { highlightSvg.style.display = ''; aria.setRole(highlightSvg, aria.Role.FIGURE); - aria.setState(highlightSvg, aria.State.LABEL, 'Open connection'); + aria.setState(highlightSvg, aria.State.ROLEDESCRIPTION, 'Connection'); + if (this.type === ConnectionType.NEXT_STATEMENT) { + const parentInput = + this.getParentInput() ?? + this.getSourceBlock() + .getTopStackBlock() + .previousConnection?.targetConnection?.getParentInput(); + if (parentInput && parentInput.type === inputTypes.STATEMENT) { + aria.setState( + highlightSvg, + aria.State.LABEL, + `${this.getParentInput() ? 'Begin' : 'End'} ${parentInput.getFieldRowLabel()}`, + ); + } + } else if ( + this.type === ConnectionType.INPUT_VALUE && + this.getSourceBlock().statementInputCount + ) { + aria.setState( + highlightSvg, + aria.State.LABEL, + `${this.getParentInput()?.getFieldRowLabel()}`, + ); + } else { + aria.setState(highlightSvg, aria.State.LABEL, 'Open connection'); + } } } diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index 02426ae26b8..2273ec4b381 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -136,22 +136,22 @@ suite('Cursor', function () { assert.equal(curNode, fieldBlock); }); - test('Prev - From previous connection does skip over next connection', function () { + test('Prev - From previous connection does not skip over next connection', function () { const prevConnection = this.blocks.B.previousConnection; const prevConnectionNode = prevConnection; this.cursor.setCurNode(prevConnectionNode); this.cursor.prev(); const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.A); + assert.equal(curNode, this.blocks.A.nextConnection); }); - test('Prev - From first block loop to last block', function () { + test('Prev - From first block loop to last statement input', function () { const prevConnection = this.blocks.A; const prevConnectionNode = prevConnection; this.cursor.setCurNode(prevConnectionNode); this.cursor.prev(); const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.D); + assert.equal(curNode, this.blocks.D.getInput('NAME4').connection); }); test('Out - From field does not skip over block node', function () { @@ -253,12 +253,16 @@ suite('Cursor', function () { test('In - from field in nested statement block to next nested statement block', function () { this.cursor.setCurNode(this.secondStatement.getField('NAME')); this.cursor.in(); + // Skip over the next connection + this.cursor.in(); const curNode = this.cursor.getCurNode(); assert.equal(curNode, this.thirdStatement); }); test('In - from field in nested statement block to next stack', function () { this.cursor.setCurNode(this.thirdStatement.getField('NAME')); this.cursor.in(); + // Skip over the next connection + this.cursor.in(); const curNode = this.cursor.getCurNode(); assert.equal(curNode, this.multiStatement2); }); @@ -266,6 +270,8 @@ suite('Cursor', function () { test('Out - from nested statement block to last field of previous nested statement block', function () { this.cursor.setCurNode(this.thirdStatement); this.cursor.out(); + // Skip over the previous next connection + this.cursor.out(); const curNode = this.cursor.getCurNode(); assert.equal(curNode, this.secondStatement.getField('NAME')); }); @@ -273,6 +279,8 @@ suite('Cursor', function () { test('Out - from root block to last field of last nested statement block in previous stack', function () { this.cursor.setCurNode(this.multiStatement2); this.cursor.out(); + // Skip over the previous next connection + this.cursor.out(); const curNode = this.cursor.getCurNode(); assert.equal(curNode, this.thirdStatement.getField('NAME')); }); @@ -395,7 +403,7 @@ suite('Cursor', function () { }); test('getLastNode', function () { const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); + assert.equal(node, this.blockA.inputList[0].connection); }); }); diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js index 38dc88894b1..3a9292b9209 100644 --- a/tests/mocha/navigation_test.js +++ b/tests/mocha/navigation_test.js @@ -531,12 +531,13 @@ suite('Navigation', function () { ); assert.equal(nextNode, field); }); - test('fromBlockToFieldSkippingInput', function () { - const field = this.blocks.buttonBlock.getField('BUTTON3'); + test('fromInputToStatementConnection', function () { + const connection = + this.blocks.buttonBlock.getInput('STATEMENT1').connection; const nextNode = this.navigator.getNextSibling( this.blocks.buttonInput2, ); - assert.equal(nextNode, field); + assert.equal(nextNode, connection); }); test('skipsChildrenOfCollapsedBlocks', function () { this.blocks.buttonBlock.setCollapsed(true); @@ -546,9 +547,9 @@ suite('Navigation', function () { test('fromFieldSkipsHiddenInputs', function () { this.blocks.buttonBlock.inputList[2].setVisible(false); const fieldStart = this.blocks.buttonBlock.getField('BUTTON2'); - const fieldEnd = this.blocks.buttonBlock.getField('BUTTON3'); + const end = this.blocks.buttonBlock.getInput('STATEMENT1').connection; const nextNode = this.navigator.getNextSibling(fieldStart); - assert.equal(nextNode.name, fieldEnd.name); + assert.equal(nextNode, end); }); }); @@ -693,9 +694,9 @@ suite('Navigation', function () { test('fromFieldSkipsHiddenInputs', function () { this.blocks.buttonBlock.inputList[2].setVisible(false); const fieldStart = this.blocks.buttonBlock.getField('BUTTON3'); - const fieldEnd = this.blocks.buttonBlock.getField('BUTTON2'); + const end = this.blocks.buttonBlock.getInput('STATEMENT1').connection; const nextNode = this.navigator.getPreviousSibling(fieldStart); - assert.equal(nextNode.name, fieldEnd.name); + assert.equal(nextNode, end); }); }); diff --git a/tsconfig.json b/tsconfig.json index f7b61f0a381..ff8353e64b8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "moduleResolution": "node", "target": "ES2020", "strict": true, + "lib": ["esnext", "dom"], // This does not understand enums only used to define other enums, so we // cannot leave it enabled. From c8a7fc66c4e9fe5d2a76840578c112a8ff184c3f Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 13 Oct 2025 12:37:21 -0700 Subject: [PATCH 18/51] feat: Remove most block tree support. (#9412) Also, use regions for identifiying toolbox, workspace, and flyout. --- core/block_svg.ts | 38 +++++----------------- core/toolbox/toolbox.ts | 10 ++++-- core/utils/aria.ts | 1 + core/workspace_svg.ts | 70 +++++------------------------------------ 4 files changed, 23 insertions(+), 96 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index f5327ceff0c..fed2d7ea112 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -300,42 +300,18 @@ export class BlockSvg private computeAriaRole() { if (this.isSimpleReporter()) { aria.setRole(this.pathObject.svgPath, aria.Role.BUTTON); - } else { - // This isn't read out by VoiceOver and it will read in the wrong place - // as a duplicate in ChromeVox due to the other changes in this branch. - // aria.setState( - // this.pathObject.svgPath, - // aria.State.ROLEDESCRIPTION, - // 'block', - // ); + } else if (this.workspace.isFlyout) { aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM); - } - } - - collectSiblingBlocks(surroundParent: BlockSvg | null): BlockSvg[] { - // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The - // returned list needs to be relatively stable for consistent block indexes - // read out to users via screen readers. - if (surroundParent) { - // Start from the first sibling and iterate in navigation order. - const firstSibling: BlockSvg = surroundParent.getChildren(false)[0]; - const siblings: BlockSvg[] = [firstSibling]; - let nextSibling: BlockSvg | null = firstSibling; - while ((nextSibling = nextSibling?.getNextBlock())) { - siblings.push(nextSibling); - } - return siblings; } else { - // For top-level blocks, simply return those from the workspace. - return this.workspace.getTopBlocks(false); + aria.setState( + this.pathObject.svgPath, + aria.State.ROLEDESCRIPTION, + 'block', + ); + aria.setRole(this.pathObject.svgPath, aria.Role.FIGURE); } } - computeLevelInWorkspace(): number { - const surroundParent = this.getSurroundParent(); - return surroundParent ? surroundParent.computeLevelInWorkspace() + 1 : 0; - } - /** * Create and initialize the SVG representation of the block. * May be called more than once. diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 2ede1bad1cb..e03b09a37f0 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -154,9 +154,6 @@ export class Toolbox this.setVisible(true); this.flyout.init(workspace); - aria.setRole(this.HtmlDiv, aria.Role.TREE); - aria.setState(this.HtmlDiv, aria.State.LABEL, Msg['TOOLBOX_ARIA_LABEL']); - this.render(this.toolboxDef_); const themeManager = workspace.getThemeManager(); themeManager.subscribe( @@ -208,6 +205,12 @@ export class Toolbox toolboxContainer.setAttribute('layout', this.isHorizontal() ? 'h' : 'v'); dom.addClass(toolboxContainer, 'blocklyToolbox'); toolboxContainer.setAttribute('dir', this.RTL ? 'RTL' : 'LTR'); + aria.setRole(toolboxContainer, aria.Role.REGION); + aria.setState( + toolboxContainer, + aria.State.LABEL, + Msg['TOOLBOX_ARIA_LABEL'], + ); return toolboxContainer; } @@ -222,6 +225,7 @@ export class Toolbox if (this.isHorizontal()) { contentsContainer.style.flexDirection = 'row'; } + aria.setRole(contentsContainer, aria.Role.TREE); return contentsContainer; } diff --git a/core/utils/aria.ts b/core/utils/aria.ts index a1f7d83b8e9..c099d10c77f 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -53,6 +53,7 @@ export enum Role { TEXTBOX = 'textbox', COMBOBOX = 'combobox', SPINBUTTON = 'spinbutton', + REGION = 'region', } /** diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index cc7da2fcf32..e09618bb9d8 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -31,7 +31,6 @@ import {WorkspaceComment} from './comments/workspace_comment.js'; import * as common from './common.js'; import {ComponentManager} from './component_manager.js'; import {ConnectionDB} from './connection_db.js'; -import {ConnectionType} from './connection_type.js'; import * as ContextMenu from './contextmenu.js'; import { ContextMenuOption, @@ -763,19 +762,15 @@ export class WorkspaceSvg }); let ariaLabel = null; - if (injectionDiv) { - ariaLabel = Msg['WORKSPACE_ARIA_LABEL']; - } else if (this.isFlyout) { + if (this.isFlyout) { ariaLabel = 'Flyout'; } else if (this.isMutator) { - ariaLabel = 'Mutator'; + ariaLabel = 'Mutator Workspace'; } else { - // This case can happen in some test scenarios. - // TODO: Figure out when this can happen in non-test scenarios (if ever). - ariaLabel = 'Workspace'; + ariaLabel = Msg['WORKSPACE_ARIA_LABEL']; } + aria.setRole(this.svgGroup_, aria.Role.REGION); aria.setState(this.svgGroup_, aria.State.LABEL, ariaLabel); - aria.setRole(this.svgGroup_, aria.Role.TREE); // Note that a alone does not receive mouse events--it must have a // valid target inside it. If no background class is specified, as in the @@ -803,7 +798,10 @@ export class WorkspaceSvg this.svgBlockCanvas_ = this.layerManager.getBlockLayer(); this.svgBubbleCanvas_ = this.layerManager.getBubbleLayer(); - if (!this.isFlyout) { + if (this.isFlyout) { + // Use the block canvas as the primary tree parent for flyout blocks. + aria.setRole(this.svgBlockCanvas_, aria.Role.TREE); + } else { browserEvents.conditionalBind( this.svgGroup_, 'pointerdown', @@ -2959,61 +2957,9 @@ export class WorkspaceSvg aria.setState(treeItemElem, aria.State.POSINSET, index + 1); aria.setState(treeItemElem, aria.State.SETSIZE, focusableItems.length); aria.setState(treeItemElem, aria.State.LEVEL, 1); // They are always top-level. - if (item instanceof BlockSvg) { - item - .getChildren(false) - .forEach((child) => - this.recomputeAriaTreeItemDetailsRecursively(child), - ); - } }); - } else { - // TODO: Do this efficiently (probably incrementally). - this.getTopBlocks(false).forEach((block) => - this.recomputeAriaTreeItemDetailsRecursively(block), - ); } } - - private recomputeAriaTreeItemDetailsRecursively(block: BlockSvg) { - const elem = block.getFocusableElement(); - const connection = block.currentConnectionCandidate; - let childPosition: number; - let parentsChildCount: number; - let hierarchyDepth: number; - if (connection) { - // If the block is being inserted into a new location, the position is hypothetical. - // TODO: Figure out how to deal with output connections. - let surroundParent: BlockSvg | null; - let siblingBlocks: BlockSvg[]; - if (connection.type === ConnectionType.INPUT_VALUE) { - surroundParent = connection.sourceBlock_; - siblingBlocks = block.collectSiblingBlocks(surroundParent); - // The block is being added as a child since it's input. - // TODO: Figure out how to compute the correct position. - childPosition = 0; - } else { - surroundParent = connection.sourceBlock_.getSurroundParent(); - siblingBlocks = block.collectSiblingBlocks(surroundParent); - // The block is being added after the connected block. - childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 1; - } - parentsChildCount = siblingBlocks.length + 1; - hierarchyDepth = surroundParent?.computeLevelInWorkspace() ?? 0; - } else { - const surroundParent = block.getSurroundParent(); - const siblingBlocks = block.collectSiblingBlocks(surroundParent); - childPosition = siblingBlocks.indexOf(block); - parentsChildCount = siblingBlocks.length; - hierarchyDepth = block.computeLevelInWorkspace(); - } - aria.setState(elem, aria.State.POSINSET, childPosition + 1); - aria.setState(elem, aria.State.SETSIZE, parentsChildCount); - aria.setState(elem, aria.State.LEVEL, hierarchyDepth + 1); - block - .getChildren(false) - .forEach((child) => this.recomputeAriaTreeItemDetailsRecursively(child)); - } } /** From 4f475c7302bda401118e11e84cc088a079c6b95e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 16 Oct 2025 14:17:00 -0700 Subject: [PATCH 19/51] fix: Miscellaneous improvements for screenreader support. (#9424) * fix: Miscellaneous improvements for screenreader support. * fix: Include field name in ARIA label. * fix: Update block ARIA labels when inputs are shown/hidden. * fix: Make field row label generation more robust. --- core/block_svg.ts | 70 ++++++++++++++++++++++++++++-------------- core/field.ts | 19 ++++++------ core/field_dropdown.ts | 21 ++++++------- core/field_image.ts | 9 +++--- core/field_input.ts | 21 ++++++++++--- core/inputs/input.ts | 21 ++++++++++--- core/menuitem.ts | 2 ++ 7 files changed, 105 insertions(+), 58 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index fed2d7ea112..00b3b816d44 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -226,7 +226,17 @@ export class BlockSvg this.computeAriaRole(); } - private recomputeAriaLabel() { + /** + * Updates the ARIA label of this block to reflect its current configuration. + * + * @internal + */ + recomputeAriaLabel() { + if (this.isSimpleReporter()) { + const field = Array.from(this.getFields())[0]; + if (field.isFullBlockField() && field.isCurrentlyEditable()) return; + } + aria.setState( this.getFocusableElement(), aria.State.LABEL, @@ -240,34 +250,42 @@ export class BlockSvg ? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}` : ''; - let currentBlock: Block | null = null; + let currentBlock: BlockSvg | null = null; let nestedStatementBlockCount = 0; - // This won't work well for if/else blocks. - this.inputList.forEach((input) => { + + for (const input of this.inputList) { if ( input.connection && input.connection.type === ConnectionType.NEXT_STATEMENT ) { - currentBlock = input.connection.targetBlock(); + currentBlock = input.connection.targetBlock() as BlockSvg | null; + while (currentBlock) { + nestedStatementBlockCount++; + currentBlock = currentBlock.getNextBlock(); + } } - }); - // The type is poorly inferred here. - while (currentBlock as Block | null) { - nestedStatementBlockCount++; - // The type is poorly inferred here. - // If currentBlock is null, we can't enter this while loop... - currentBlock = currentBlock!.getNextBlock(); } let blockTypeText = 'block'; if (this.isShadow()) { - blockTypeText = 'input block'; - } else if (this.outputConnection) { blockTypeText = 'replacable block'; + } else if (this.outputConnection) { + blockTypeText = 'input block'; } else if (this.statementInputCount) { blockTypeText = 'C-shaped block'; } + const modifiers = []; + if (!this.isEnabled()) { + modifiers.push('disabled'); + } + if (this.isCollapsed()) { + modifiers.push('collapsed'); + } + if (modifiers.length) { + blockTypeText = `${modifiers.join(' ')} ${blockTypeText}`; + } + let prefix = ''; const parentInput = ( this.previousConnection ?? this.outputConnection @@ -298,9 +316,7 @@ export class BlockSvg } private computeAriaRole() { - if (this.isSimpleReporter()) { - aria.setRole(this.pathObject.svgPath, aria.Role.BUTTON); - } else if (this.workspace.isFlyout) { + if (this.workspace.isFlyout) { aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM); } else { aria.setState( @@ -335,8 +351,6 @@ export class BlockSvg if (!svg.parentNode) { this.workspace.getCanvas().appendChild(svg); } - // Note: This must be done after initialization of the block's fields. - this.recomputeAriaLabel(); this.initialized = true; } @@ -672,6 +686,7 @@ export class BlockSvg this.removeInput(collapsedInputName); dom.removeClass(this.svgGroup, 'blocklyCollapsed'); this.setWarningText(null, BlockSvg.COLLAPSED_WARNING_ID); + this.recomputeAriaLabel(); return; } @@ -693,6 +708,8 @@ export class BlockSvg this.getInput(collapsedInputName) || this.appendDummyInput(collapsedInputName); input.appendField(new FieldLabel(text), collapsedFieldName); + + this.recomputeAriaLabel(); } /** @@ -1108,6 +1125,8 @@ export class BlockSvg for (const child of this.getChildren(false)) { child.updateDisabled(); } + + this.recomputeAriaLabel(); } /** @@ -1752,8 +1771,6 @@ export class BlockSvg * settings. */ render() { - this.recomputeAriaLabel(); - this.queueRender(); renderManagement.triggerQueuedRenders(); } @@ -1765,8 +1782,6 @@ export class BlockSvg * @internal */ renderEfficiently() { - this.recomputeAriaLabel(); - dom.startTextWidthCache(); if (this.isCollapsed()) { @@ -1948,6 +1963,12 @@ export class BlockSvg /** See IFocusableNode.getFocusableElement. */ getFocusableElement(): HTMLElement | SVGElement { + if (this.isSimpleReporter()) { + const field = Array.from(this.getFields())[0]; + if (field && field.isFullBlockField() && field.isCurrentlyEditable()) { + return field.getFocusableElement(); + } + } return this.pathObject.svgPath; } @@ -1958,6 +1979,7 @@ export class BlockSvg /** See IFocusableNode.onNodeFocus. */ onNodeFocus(): void { + this.recomputeAriaLabel(); this.select(); this.workspace.scrollBoundsIntoView( this.getBoundingRectangleWithoutChildren(), @@ -2038,6 +2060,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary { return block.inputList .flatMap((input) => { const fields = input.fieldRow.map((field) => { + if (!field.isVisible()) return []; // If the block is a full block field, we only want to know if it's an // editable field if we're not directly on it. if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) { @@ -2046,6 +2069,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary { return [field.getText() ?? field.getValue()]; }); if ( + input.isVisible() && input.connection && input.connection.type === ConnectionType.INPUT_VALUE ) { diff --git a/core/field.ts b/core/field.ts index 79fd8c9f077..234308d69a9 100644 --- a/core/field.ts +++ b/core/field.ts @@ -196,9 +196,6 @@ export abstract class Field */ SERIALIZABLE = false; - /** The unique ID of this field. */ - private id_: string | null = null; - private config: FieldConfig | null = null; /** @@ -272,7 +269,6 @@ export abstract class Field `problems with focus: ${block.id}.`, ); } - this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; } getAriaName(): string | null { @@ -327,11 +323,8 @@ 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'; } @@ -343,6 +336,14 @@ export abstract class Field this.bindEvents_(); this.initModel(); this.applyColour(); + + const id = + this.isFullBlockField() && + this.isCurrentlyEditable() && + this.sourceBlock_?.isSimpleReporter() + ? idGenerator.getNextUniqueId() + : `${this.sourceBlock_?.id}_field_${idGenerator.getNextUniqueId()}`; + this.fieldGroup_.setAttribute('id', id); } /** diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 7b6e8d5e821..12ef0c27f03 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -202,7 +202,7 @@ export class FieldDropdown extends Field { this.recomputeAria(); } - private recomputeAria() { + protected recomputeAria() { if (!this.fieldGroup_) return; // There's no element to set currently. const element = this.getFocusableElement(); aria.setRole(element, aria.Role.COMBOBOX); @@ -213,17 +213,15 @@ export class FieldDropdown extends Field { } else { aria.clearState(element, aria.State.CONTROLS); } - aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Dropdown'); - // Ensure the selected item has its correct label presented since it may be - // different than the actual text presented to the user. - if (this.textElement_) { - aria.setState( - this.textElement_, - aria.State.LABEL, - this.computeLabelForOption(this.selectedOption), - ); - } + const label = [ + this.computeLabelForOption(this.selectedOption), + this.getAriaName(), + ] + .filter((item) => !!item) + .join(', '); + + aria.setState(element, aria.State.LABEL, label); } /** @@ -645,7 +643,6 @@ export class FieldDropdown extends Field { const element = this.getFocusableElement(); aria.setState(element, aria.State.ACTIVEDESCENDANT, textElement.id); } - aria.setState(textElement, aria.State.HIDDEN, false); // Height and width include the border rect. const hasBorder = !!this.borderRect_; diff --git a/core/field_image.ts b/core/field_image.ts index ae66eae3d4d..91e6dccdcb6 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -163,11 +163,10 @@ export class FieldImage extends Field { aria.setRole(element, aria.Role.IMAGE); } - aria.setState( - element, - aria.State.LABEL, - this.altText ?? this.getAriaName(), - ); + const label = [this.altText, this.getAriaName()] + .filter((item) => !!item) + .join(', '); + aria.setState(element, aria.State.LABEL, label); } override updateSize_() {} diff --git a/core/field_input.ts b/core/field_input.ts index 216dad11345..5d4d3bdb397 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -178,13 +178,23 @@ export abstract class FieldInput extends Field< dom.addClass(this.fieldGroup_, 'blocklyInputField'); } - // Showing the text-based value with the input's textbox ensures that the - // input's value is correctly read out by screen readers with its role. - aria.setState(this.textElement_, aria.State.HIDDEN, false); + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.BUTTON); + this.recomputeAriaLabel(); + } + + /** + * Updates the ARIA label for this field. + */ + protected recomputeAriaLabel() { + if (!this.fieldGroup_) return; const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.TEXTBOX); - aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Text'); + const label = [this.getValue(), this.getAriaName()] + .filter((item) => !!item) + .join(', '); + + aria.setState(element, aria.State.LABEL, label); } override isFullBlockField(): boolean { @@ -248,6 +258,7 @@ export abstract class FieldInput extends Field< this.isDirty_ = true; this.isTextValid_ = true; this.value_ = newValue; + this.recomputeAriaLabel(); } /** diff --git a/core/inputs/input.ts b/core/inputs/input.ts index c6f75712a3b..5195e5d68f2 100644 --- a/core/inputs/input.ts +++ b/core/inputs/input.ts @@ -193,6 +193,9 @@ export class Input { child.getSvgRoot().style.display = visible ? 'block' : 'none'; } } + if (this.sourceBlock.rendered) { + (this.sourceBlock as BlockSvg).recomputeAriaLabel(); + } return renderList; } @@ -312,10 +315,20 @@ export class Input { * @internal * @returns A description of this input's row on its parent block. */ - getFieldRowLabel() { - return this.fieldRow.reduce((label, field) => { - return `${label} ${field.EDITABLE ? field.getAriaName() : field.getValue()}`; - }, ''); + getFieldRowLabel(): string { + const fieldRowLabel = this.fieldRow + .reduce((label, field) => { + return `${label} ${field.getValue()}`; + }, '') + .trim(); + if (!fieldRowLabel) { + const inputs = this.getSourceBlock().inputList; + const index = inputs.indexOf(this); + if (index > 0) { + return inputs[index - 1].getFieldRowLabel(); + } + } + return fieldRowLabel; } /** diff --git a/core/menuitem.ts b/core/menuitem.ts index 2a36620ee72..aada3aa0ab0 100644 --- a/core/menuitem.ts +++ b/core/menuitem.ts @@ -75,10 +75,12 @@ export class MenuItem { const content = document.createElement('div'); content.className = 'blocklyMenuItemContent'; + aria.setRole(content, aria.Role.PRESENTATION); // Add a checkbox for checkable menu items. if (this.checkable) { const checkbox = document.createElement('div'); checkbox.className = 'blocklyMenuItemCheckbox '; + aria.setRole(checkbox, aria.Role.PRESENTATION); content.appendChild(checkbox); } From ae3a610ba50ad526adee9a730875e18e0b098a43 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 27 Oct 2025 10:35:07 -0700 Subject: [PATCH 20/51] fix: Don't filter out falsey input values when generating ARIA labels. (#9439) --- core/field_input.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/field_input.ts b/core/field_input.ts index 5d4d3bdb397..244c6da4269 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -191,7 +191,7 @@ export abstract class FieldInput extends Field< const element = this.getFocusableElement(); const label = [this.getValue(), this.getAriaName()] - .filter((item) => !!item) + .filter((item) => item !== null) .join(', '); aria.setState(element, aria.State.LABEL, label); From 7e77b107069a71c0364931063f427bb3ea8a76ce Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 27 Oct 2025 12:51:44 -0700 Subject: [PATCH 21/51] fix: Make up/previous navigation consistent with down/next. (#9437) * fix: Make up/previous navigation consistent with down/next. * fix: Don't visit nested input blocks when moving up/previous. --- core/keyboard_nav/block_navigation_policy.ts | 14 +++--- core/keyboard_nav/line_cursor.ts | 46 +++++++++++++++++--- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 93fc93dd199..f79efcf2529 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -137,9 +137,9 @@ function getBlockNavigationCandidates( .lastConnectionInStack(false) ?.getSourceBlock(); if (lastStackBlock) { - // When navigating backward, the last block in a stack in a statement - // input is navigable. - candidates.push(lastStackBlock); + // When navigating backward, the last next connection in a stack in a + // statement input is navigable. + candidates.push(lastStackBlock.nextConnection); } } else { // When navigating forward, a child block connected to a statement @@ -198,9 +198,13 @@ export function navigateStacks(current: ISelectable, delta: number) { } // When navigating to a previous block stack, our previous sibling is the last - // block in it. + // block or nested next connection in it. if (delta < 0 && result instanceof BlockSvg) { - return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; + result = result.lastConnectionInStack(false)?.getSourceBlock() ?? result; + + if (result instanceof BlockSvg && result.statementInputCount > 0) { + result = getBlockNavigationCandidates(result, false).at(-1) ?? result; + } } return result; diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 1ca61041898..549e51a9352 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -338,8 +338,44 @@ export class LineCursor extends Marker { return true; } - const current = this.getSourceBlockFromNode(this.getCurNode()); - if (candidate instanceof BlockSvg && current instanceof BlockSvg) { + const currentNode = this.getCurNode(); + if (direction === NavigationDirection.PREVIOUS) { + // Don't visit rightmost/nested blocks in statement blocks when + // navigating to the previous block. + if ( + currentNode instanceof RenderedConnection && + currentNode.type === ConnectionType.NEXT_STATEMENT && + !currentNode.getParentInput() && + candidate !== currentNode.getSourceBlock() + ) { + return false; + } + + // Don't visit the first value/input block in a block with statement + // inputs when navigating to the previous block. This is consistent + // with the behavior when navigating to the next block and avoids + // duplicative screen reader narration. Also don't visit value + // blocks nested in non-statement inputs. + if ( + candidate instanceof BlockSvg && + candidate.outputConnection?.targetConnection + ) { + const parentInput = + candidate.outputConnection.targetConnection.getParentInput(); + if ( + !parentInput?.getSourceBlock().statementInputCount || + parentInput?.getSourceBlock().inputList[0] === parentInput + ) { + return false; + } + } + } + + const currentBlock = this.getSourceBlockFromNode(currentNode); + if ( + candidate instanceof BlockSvg && + currentBlock instanceof BlockSvg + ) { // If the candidate's parent uses inline inputs, disallow the // candidate; it follows that it must be on the same row as its // parent. @@ -352,13 +388,13 @@ export class LineCursor extends Marker { // block, disallow it; it cannot be on a different row than the // current block. if ( - current === this.getCurNode() && - candidateParents.has(current) + currentBlock === this.getCurNode() && + candidateParents.has(currentBlock) ) { return false; } - const currentParents = this.getParents(current); + const currentParents = this.getParents(currentBlock); const sharedParents = currentParents.intersection(candidateParents); // Allow the candidate if it and the current block have no parents From 1f6dd0e3e280e49c22e34df2e9cd85884c349981 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 4 Nov 2025 13:44:34 -0800 Subject: [PATCH 22/51] fix: make the aria live region assertive (#9436) --- core/inject.ts | 2 +- core/utils/aria.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/core/inject.ts b/core/inject.ts index 3df73c41203..f5f04b5c387 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -86,7 +86,7 @@ export function inject( const ariaAnnouncementSpan = document.createElement('span'); ariaAnnouncementSpan.id = 'blocklyAriaAnnounce'; dom.addClass(ariaAnnouncementSpan, 'hiddenForAria'); - aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'polite'); + aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'assertive'); subContainer.appendChild(ariaAnnouncementSpan); return workspace; diff --git a/core/utils/aria.ts b/core/utils/aria.ts index c099d10c77f..84d4e2312c4 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -202,20 +202,20 @@ export function getState(element: Element, stateName: State): string | null { } /** - * Softly requests that the specified text be read to the user if a screen + * Assertively requests that the specified text be read to the user if a screen * reader is currently active. * - * This relies on a centrally managed ARIA live region that should not interrupt - * existing announcements (that is, this is what's considered a polite - * announcement). + * This relies on a centrally managed ARIA live region that is hidden from the + * visual DOM. This live region is assertive, meaning it will interrupt other + * text being read. * * Callers should use this judiciously. It's often considered bad practice to - * over announce information that can be inferred from other sources on the - * page, so this ought to only be used when certain context cannot be easily + * over-announce information that can be inferred from other sources on the + * page, so this ought to be used only when certain context cannot be easily * determined (such as dynamic states that may not have perfect ARIA * representations or indications). * - * @param text The text to politely read to the user. + * @param text The text to read to the user. */ export function announceDynamicAriaState(text: string) { const ariaAnnouncementSpan = document.getElementById('blocklyAriaAnnounce'); From f2b332fe717c0550a048542014cc0aa7575df4d5 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 12 Nov 2025 18:09:30 -0800 Subject: [PATCH 23/51] Merge pull request #9446 from BenHenning/fix-miscellaneous-screen-reader-issues ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9301 Fixes #9312 Fixes #9313 Fixes part of #9304 ### Proposed Changes This introduces a variety of specific changes to resolve several issues for screen reader work, including introducing fundamental support for field labeling. Specifically: - Field labels have been simplified to only use their custom defined ARIA name otherwise they are null (and thus should be ignored for readout purposes) which wraps up the remaining high-level work for #9301 (#9450 tracks more specific follow-up work to improve upon what's been established at this point). The PR also introduces an ARIA override for number inputs in math blocks so that the readout is correct for them. - Bubble labeling is more explicit now which is useful for mutators (#9312), warnings, and comments. The general improvement for bubbles wraps up the remaining work for #9313 as well since the core issue was resolved in #9351. By default a bubble has no ARIA label. - #9304 is partly being addressed here with the change to field images: they are no longer being added to the accessibility node tree unless they are actually navigable (that is, clickable). Part of #9304's goal is to remove extraneous nodes. - Finally, a typo was fixed for 'replaceable blocks' since these were not reading out correctly. This was noticed in passing and isn't directly related to the other issues. ### Reason for Changes This PR is largely being used as a basis for one particularly significant issue: #9301. Field labeling has undergone several iterations over the past few months and the team seems comfortable sticking with a "do as little as possible" approach when determining the label, thus justifying the need for expecting more specific customization (i.e. #9450). To this end it's important to be clear that getting fields to a good state is not actually "done" but the need to track it as a large incomplete thing has ended. Note that one important part of #9301 was updating field plugins to be accessible--this largely seems unnecessary as-is as it will be completely dependent on the needs of future user tests. The long-term plan will need to account for making all fields in `blockly-samples` accessible (per #9307). Some of the terminology used here (e.g. for bubbles) will likely need to change after user testing, but it's important to establish that _something_ correct is communicated even if the terminology may require scaffolding and/or refinement. It's important to note that while non-clickable field images are no longer in the node graph, their ARIA presence still exists as part of the fluent block labeling solution. That is, `FieldImage`'s alt text is used as part of constructing a fluent block label (sometimes to confusing effect--see #9452). ### Test Coverage No tests needed since these are experimental changes and do not change existing test behaviors. ### Documentation No documentation changes are needed for these experimental changes. ### Additional Information None. --- blocks/math.ts | 1 + core/block_svg.ts | 2 +- core/bubbles/bubble.ts | 9 +++++++++ core/bubbles/mini_workspace_bubble.ts | 4 ++++ core/bubbles/text_bubble.ts | 4 ++++ core/bubbles/textinput_bubble.ts | 4 ++++ core/field.ts | 9 +-------- core/field_image.ts | 15 ++++++++------- 8 files changed, 32 insertions(+), 16 deletions(-) diff --git a/blocks/math.ts b/blocks/math.ts index b3f93321125..b756967832e 100644 --- a/blocks/math.ts +++ b/blocks/math.ts @@ -32,6 +32,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([ 'type': 'field_number', 'name': 'NUM', 'value': 0, + 'ariaName': 'Number', }, ], 'output': 'Number', diff --git a/core/block_svg.ts b/core/block_svg.ts index 00b3b816d44..133fc609641 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -268,7 +268,7 @@ export class BlockSvg let blockTypeText = 'block'; if (this.isShadow()) { - blockTypeText = 'replacable block'; + blockTypeText = 'replaceable block'; } else if (this.outputConnection) { blockTypeText = 'input block'; } else if (this.statementInputCount) { diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index 8a6bacac54d..2967b418157 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -144,6 +144,8 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { this.focusableElement = overriddenFocusableElement ?? this.svgRoot; this.focusableElement.setAttribute('id', this.id); aria.setRole(this.focusableElement, aria.Role.GROUP); + const label = this.getAriaLabel(); + if (label) aria.setState(this.focusableElement, aria.State.LABEL, label); browserEvents.conditionalBind( this.background, @@ -166,6 +168,13 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { this.disposed = true; } + /** + * @returns The ARIA label to use for this bubble, or null if none should be used. + */ + protected getAriaLabel(): string | null { + return null; + } + /** * Set the location the tail of this bubble points to. * diff --git a/core/bubbles/mini_workspace_bubble.ts b/core/bubbles/mini_workspace_bubble.ts index 194cb41f35d..303452e7a33 100644 --- a/core/bubbles/mini_workspace_bubble.ts +++ b/core/bubbles/mini_workspace_bubble.ts @@ -89,6 +89,10 @@ export class MiniWorkspaceBubble extends Bubble { this.updateBubbleSize(); } + protected override getAriaLabel(): string | null { + return 'Mutator Bubble'; + } + dispose() { this.miniWorkspace.dispose(); super.dispose(); diff --git a/core/bubbles/text_bubble.ts b/core/bubbles/text_bubble.ts index 99299fa50e8..a67f2d6cf06 100644 --- a/core/bubbles/text_bubble.ts +++ b/core/bubbles/text_bubble.ts @@ -30,6 +30,10 @@ export class TextBubble extends Bubble { dom.addClass(this.svgRoot, 'blocklyTextBubble'); } + protected override getAriaLabel(): string | null { + return 'Warning Bubble'; + } + /** @returns the current text of this text bubble. */ getText(): string { return this.text; diff --git a/core/bubbles/textinput_bubble.ts b/core/bubbles/textinput_bubble.ts index 0bad5fabce6..4420d05be0a 100644 --- a/core/bubbles/textinput_bubble.ts +++ b/core/bubbles/textinput_bubble.ts @@ -87,6 +87,10 @@ export class TextInputBubble extends Bubble { this.setSize(this.DEFAULT_SIZE, true); } + protected override getAriaLabel(): string | null { + return 'Comment Bubble'; + } + /** @returns the text of this bubble. */ getText(): string { return this.editor.getText(); diff --git a/core/field.ts b/core/field.ts index 234308d69a9..ccbd3442275 100644 --- a/core/field.ts +++ b/core/field.ts @@ -272,14 +272,7 @@ export abstract class Field } getAriaName(): string | null { - return ( - this.config?.ariaName ?? - this.config?.name ?? - this.config?.type ?? - this.getSourceBlock()?.type ?? - this.name ?? - null - ); + return this.config?.ariaName ?? null; } /** diff --git a/core/field_image.ts b/core/field_image.ts index 91e6dccdcb6..2b5a3139c71 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -156,17 +156,18 @@ export class FieldImage extends Field { } const element = this.getFocusableElement(); - if (this.clickHandler) { + if (this.isClickable()) { this.imageElement.style.cursor = 'pointer'; aria.setRole(element, aria.Role.BUTTON); + + const label = [this.altText, this.getAriaName()] + .filter((item) => !!item) + .join(', '); + aria.setState(element, aria.State.LABEL, label); } else { - aria.setRole(element, aria.Role.IMAGE); + // The field isn't navigable unless it's clickable. + aria.setRole(element, aria.Role.PRESENTATION); } - - const label = [this.altText, this.getAriaName()] - .filter((item) => !!item) - .join(', '); - aria.setState(element, aria.State.LABEL, label); } override updateSize_() {} From 141e28d1de3446c41cff5ee167977ebf9face83c Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 19 Nov 2025 14:31:04 -0800 Subject: [PATCH 24/51] fix: Correct ARIA label is for invalid values. (#9469) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9466 ### Proposed Changes Ensures `FieldInput`'s ARIA labels are recomputed when an invalid value is attempted to be set. ### Reason for Changes Previously the `FieldInput` would continuously update its ARIA label as the value changed, including for invalid values. If the editor was cancelled this would correctly revert but if an invalid value was attempted to be saved then it would cancel the update but not correct the ARIA label. ### Test Coverage No new tests are needed for this experimental change. This has been manually verified locally using `FieldNumber` in Core's advanced playground. ### Documentation No documentation changes are needed for this experimental change. ### Additional Information None. --- core/field_input.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/field_input.ts b/core/field_input.ts index 244c6da4269..696e2307986 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -227,6 +227,7 @@ export abstract class FieldInput extends Field< const oldValue = this.value_; // Revert value when the text becomes invalid. this.value_ = this.valueWhenEditorWasOpened_; + this.recomputeAriaLabel(); if ( this.sourceBlock_ && eventUtils.isEnabled() && From 7a3af804614b3320f5632c3931b1116a2fad9d69 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 19 Nov 2025 17:38:20 -0800 Subject: [PATCH 25/51] fix: Remove extra and problematic ARIA settings (#9478) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9461 ### Proposed Changes Remove image-specific ARIA properties (role and label) for the current selected item in `FieldDropdown`. ### Reason for Changes This intentionally changes a behavior introduced in #9384 because the alternative ARIA behavior works better. As the images in #9461 show the actual focus outline when the current drop-down value has ARIA properties goes to that element (separate from active focus in this case) since the screen reader will read out those properties. However, this makes the reader lose context on the combo box itself. This basically goes away from leveraging active descendant (though that's still necessary for correct combo box implementation) and just forces the label in a way that keeps the combo box context. Text-based combo boxes already do this, so this change simply brings image-based items in alignment with text combo box behaviors. ### Test Coverage No new automated tests are needed for this experimental change. Manual testing was done via core Blockly's advanced playground (using the 'test blocks' toolbox). ### Documentation No documentation changes are needed. ### Additional Information None. --- core/field_dropdown.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 12ef0c27f03..8cefd9c825f 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -624,9 +624,6 @@ export class FieldDropdown extends Field { const element = this.getFocusableElement(); aria.setState(element, aria.State.ACTIVEDESCENDANT, imageElement.id); } - - aria.setRole(imageElement, aria.Role.IMAGE); - aria.setState(imageElement, aria.State.LABEL, imageJson.alt); } /** Renders the selected option, which must be text. */ From 0aa176e6e90a40fe2a600188e48b621270f991c9 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 20 Nov 2025 17:41:49 -0800 Subject: [PATCH 26/51] fix: Remove clickable images from block summary. (#9480) --- core/block_svg.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 133fc609641..d312f418a43 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -33,6 +33,7 @@ import {BlockDragStrategy} from './dragging/block_drag_strategy.js'; import type {BlockMove} from './events/events_block_move.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; +import {FieldImage} from './field_image.js'; import {FieldLabel} from './field_label.js'; import {getFocusManager} from './focus_manager.js'; import {IconType} from './icons/icon_types.js'; @@ -2059,15 +2060,22 @@ function buildBlockSummary(block: BlockSvg): BlockSummary { ): string { return block.inputList .flatMap((input) => { - const fields = input.fieldRow.map((field) => { - if (!field.isVisible()) return []; - // If the block is a full block field, we only want to know if it's an - // editable field if we're not directly on it. - if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) { - inputCount++; - } - return [field.getText() ?? field.getValue()]; - }); + const fields = input.fieldRow + .filter((field) => { + if (!field.isVisible()) return false; + if (field instanceof FieldImage && field.isClickable()) { + return false; + } + return true; + }) + .map((field) => { + // If the block is a full block field, we only want to know if it's an + // editable field if we're not directly on it. + if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) { + inputCount++; + } + return [field.getText() ?? field.getValue()]; + }); if ( input.isVisible() && input.connection && From 38e30fa052339eab1c6bfd93e64f9c30492913c4 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 2 Dec 2025 20:32:15 +0000 Subject: [PATCH 27/51] feat: Expand single field block labeling (experimental) (#9484) ## The basics - [ ] ~I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)~ (I can't really due to the nature of this change--I have to rely on tests and external testing) ## The details ### Resolves Fixes part of #9456 (remainder will be in a change in the keyboard navigation plugin) ### Proposed Changes Introduce and use new `Block` function for retrieving a configurably constrained singleton field for a given block. The constraints allow for some level of configuring (such as whether to isolate to only full or editable blocks). The existing simple reporter function has been retrofitted to use this new function, instead. ### Reason for Changes This expanded support fixes the underlying use case. Separately, this change reveals two noteworthy details: 1. There's inconsistency in the codebase as to when the singleton field needs to be editable, a full-block field, both, and neither. It would be ideal to make this consistent. Interestingly, the documentation for `isSimpleReporter` seems to have been wrong since it wasn't actually fulfilling its contract of returning an editable field (this has been retained for callsites except where the check was already happening). 2. There's a possible recursion case now possible between `getSingletonFullBlockField` and `isFullBlockField` due to `FieldInput`'s `isFullBlockField` depending on `isSimpleReporter`. Ideally this would be changed in the future to avoid that potential recursion risk (possibly as part of #9307). ### Test Coverage No new automated tests are needed for this experimental work. Manual testing mainly comprised of cursory navigation and readout checks for single-field blocks to make sure nothing breaks. More thorough testing is difficult in core since the specific situation of multiple fields don't have a corresponding block to use in the playground to verify. Automated tests are also being heavily relied on for correctness since all of the nuance behind the simple reporter cases would require a deeper testing pass. ### Documentation No new documentation needed for this experimental work. ### Additional Information None. --- core/block.ts | 41 ++++++++++++++++---- core/block_svg.ts | 14 ++----- core/contextmenu_items.ts | 8 +--- core/field.ts | 9 ++--- core/keyboard_nav/field_navigation_policy.ts | 5 +-- 5 files changed, 42 insertions(+), 35 deletions(-) diff --git a/core/block.ts b/core/block.ts index af44facda5d..faa0ae324eb 100644 --- a/core/block.ts +++ b/core/block.ts @@ -962,16 +962,43 @@ export class Block { } /** - * @returns True if this block is a value block with a single editable field. + * @returns True if this block is a value block with a full block field. + * @param mustBeFullBlock Whether the evaluated field must be 'full-block'. + * @param mustBeEditable Whether the evaluated field must be editable. * @internal */ - isSimpleReporter(): boolean { - if (!this.outputConnection) return false; + isSimpleReporter( + mustBeFullBlock: boolean = false, + mustBeEditable: boolean = false, + ): boolean { + return ( + this.getSingletonFullBlockField(mustBeFullBlock, mustBeEditable) !== null + ); + } - for (const input of this.inputList) { - if (input.connection || input.fieldRow.length > 1) return false; - } - return true; + /** + * Determines and returns the only field of this block, or null if there isn't + * one and this block can't be considered a simple reporter. Null will also be + * returned if the singleton block doesn't match additional criteria, if set, + * such as being full-block or editable. + * + * @param mustBeFullBlock Whether the returned field must be 'full-block'. + * @param mustBeEditable Whether the returned field must be editable. + * @returns The only full-block, maybe editable field of this block, or null. + * @internal + */ + getSingletonFullBlockField( + mustBeFullBlock: boolean, + mustBeEditable: boolean, + ): Field | null { + if (!this.outputConnection) return null; + for (const input of this.inputList) if (input.connection) return null; + const matchingFields = Array.from(this.getFields()).filter((field) => { + if (mustBeFullBlock && !field.isFullBlockField()) return false; + if (mustBeEditable && !field.isCurrentlyEditable()) return false; + return true; + }); + return matchingFields.length === 1 ? matchingFields[0] : null; } /** diff --git a/core/block_svg.ts b/core/block_svg.ts index d312f418a43..50f76e50fc1 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -233,10 +233,7 @@ export class BlockSvg * @internal */ recomputeAriaLabel() { - if (this.isSimpleReporter()) { - const field = Array.from(this.getFields())[0]; - if (field.isFullBlockField() && field.isCurrentlyEditable()) return; - } + if (this.isSimpleReporter(true, true)) return; aria.setState( this.getFocusableElement(), @@ -1964,13 +1961,8 @@ export class BlockSvg /** See IFocusableNode.getFocusableElement. */ getFocusableElement(): HTMLElement | SVGElement { - if (this.isSimpleReporter()) { - const field = Array.from(this.getFields())[0]; - if (field && field.isFullBlockField() && field.isCurrentlyEditable()) { - return field.getFocusableElement(); - } - } - return this.pathObject.svgPath; + const singletonField = this.getSingletonFullBlockField(true, true); + return singletonField?.getFocusableElement() ?? this.pathObject.svgPath; } /** See IFocusableNode.getFocusableTree. */ diff --git a/core/contextmenu_items.ts b/core/contextmenu_items.ts index 001a3c58e25..764758322b7 100644 --- a/core/contextmenu_items.ts +++ b/core/contextmenu_items.ts @@ -26,12 +26,6 @@ import {Coordinate} from './utils/coordinate.js'; import * as svgMath from './utils/svg_math.js'; import type {WorkspaceSvg} from './workspace_svg.js'; -function isFullBlockField(block?: BlockSvg) { - if (!block || !block.isSimpleReporter()) return false; - const firstField = block.getFields().next().value; - return firstField?.isFullBlockField(); -} - /** * Option to undo previous action. */ @@ -377,7 +371,7 @@ export function registerComment() { // Either block already has a comment so let us remove it, // or the block isn't just one full-block field block, which // shouldn't be allowed to have comments as there's no way to read them. - (block.hasIcon(CommentIcon.TYPE) || !isFullBlockField(block)) + (block.hasIcon(CommentIcon.TYPE) || !block.isSimpleReporter(true)) ) { return 'enabled'; } diff --git a/core/field.ts b/core/field.ts index ccbd3442275..3bb3d5caf40 100644 --- a/core/field.ts +++ b/core/field.ts @@ -330,12 +330,9 @@ export abstract class Field this.initModel(); this.applyColour(); - const id = - this.isFullBlockField() && - this.isCurrentlyEditable() && - this.sourceBlock_?.isSimpleReporter() - ? idGenerator.getNextUniqueId() - : `${this.sourceBlock_?.id}_field_${idGenerator.getNextUniqueId()}`; + const id = this.sourceBlock_?.isSimpleReporter(true, true) + ? idGenerator.getNextUniqueId() + : `${this.sourceBlock_?.id}_field_${idGenerator.getNextUniqueId()}`; this.fieldGroup_.setAttribute('id', id); } diff --git a/core/keyboard_nav/field_navigation_policy.ts b/core/keyboard_nav/field_navigation_policy.ts index f9df406c22c..fb332a1cf85 100644 --- a/core/keyboard_nav/field_navigation_policy.ts +++ b/core/keyboard_nav/field_navigation_policy.ts @@ -65,10 +65,7 @@ export class FieldNavigationPolicy implements INavigationPolicy> { current.canBeFocused() && current.isVisible() && (current.isClickable() || current.isCurrentlyEditable()) && - !( - current.getSourceBlock()?.isSimpleReporter() && - current.isFullBlockField() - ) && + !current.getSourceBlock()?.isSimpleReporter(true, true) && current.getParentInput().isVisible() ); } From b2a266a22bf75c88bc9b52b0e51fcf0b585bbece Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 3 Dec 2025 09:49:31 -0800 Subject: [PATCH 28/51] fix: Remove count of child blocks from ARIA labels (#9503) --- core/block_svg.ts | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 50f76e50fc1..9475fad048f 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -248,22 +248,6 @@ export class BlockSvg ? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}` : ''; - let currentBlock: BlockSvg | null = null; - let nestedStatementBlockCount = 0; - - for (const input of this.inputList) { - if ( - input.connection && - input.connection.type === ConnectionType.NEXT_STATEMENT - ) { - currentBlock = input.connection.targetBlock() as BlockSvg | null; - while (currentBlock) { - nestedStatementBlockCount++; - currentBlock = currentBlock.getNextBlock(); - } - } - } - let blockTypeText = 'block'; if (this.isShadow()) { blockTypeText = 'replaceable block'; @@ -299,15 +283,8 @@ export class BlockSvg } let additionalInfo = blockTypeText; - if (inputSummary && !nestedStatementBlockCount) { + if (inputSummary) { additionalInfo = `${additionalInfo} with ${inputSummary}`; - } else if (nestedStatementBlockCount) { - const childBlockSummary = `${nestedStatementBlockCount} child ${nestedStatementBlockCount > 1 ? 'blocks' : 'block'}`; - if (inputSummary) { - additionalInfo = `${additionalInfo} with ${inputSummary} and ${childBlockSummary}`; - } else { - additionalInfo = `${additionalInfo} with ${childBlockSummary}`; - } } return prefix + blockSummary + ', ' + additionalInfo; From 0f7cddede32c70e94277b27befa6428a8934ddf4 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 3 Dec 2025 09:57:59 -0800 Subject: [PATCH 29/51] feat: Identify root stack blocks in ARIA label (#9501) --- core/block_svg.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/block_svg.ts b/core/block_svg.ts index 9475fad048f..913781e3a06 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -282,6 +282,10 @@ export class BlockSvg prefix = `${parentInput.getFieldRowLabel()} `; } + if (this.getRootBlock() === this) { + prefix = 'Begin stack, ' + prefix; + } + let additionalInfo = blockTypeText; if (inputSummary) { additionalInfo = `${additionalInfo} with ${inputSummary}`; From 27372a6f08b901d974a6771f2e890e41ae91d653 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 3 Dec 2025 18:13:02 +0000 Subject: [PATCH 30/51] feat: Add comma-separation to ARIA labels (#9505) * feat: Add comma-separation to ARIA labels * fix: remove unneeded complexity of returned comma-separated string --- core/block_svg.ts | 128 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 91 insertions(+), 37 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 913781e3a06..b90e101a17a 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -243,7 +243,7 @@ export class BlockSvg } private computeAriaLabel(): string { - const {blockSummary, inputCount} = buildBlockSummary(this); + const {commaSeparatedSummary, inputCount} = buildBlockSummary(this); const inputSummary = inputCount ? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}` : ''; @@ -291,7 +291,7 @@ export class BlockSvg additionalInfo = `${additionalInfo} with ${inputSummary}`; } - return prefix + blockSummary + ', ' + additionalInfo; + return prefix + commaSeparatedSummary + ', ' + additionalInfo; } private computeAriaRole() { @@ -2022,57 +2022,111 @@ export class BlockSvg interface BlockSummary { blockSummary: string; + commaSeparatedSummary: string; inputCount: number; } function buildBlockSummary(block: BlockSvg): BlockSummary { let inputCount = 0; + + // Produce structured segments + // For example, the block: + // "create list with item foo repeated 5 times" + // becomes: + // LABEL("create list with item"), + // INPUT("foo"), + // LABEL("repeated") + // INPUT("5"), + // LABEL("times") + type SummarySegment = + | {kind: 'label'; text: string} + | {kind: 'input'; text: string}; + function recursiveInputSummary( block: BlockSvg, isNestedInput: boolean = false, - ): string { - return block.inputList - .flatMap((input) => { - const fields = input.fieldRow - .filter((field) => { - if (!field.isVisible()) return false; - if (field instanceof FieldImage && field.isClickable()) { - return false; - } - return true; - }) - .map((field) => { - // If the block is a full block field, we only want to know if it's an - // editable field if we're not directly on it. - if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) { - inputCount++; - } - return [field.getText() ?? field.getValue()]; - }); - if ( - input.isVisible() && - input.connection && - input.connection.type === ConnectionType.INPUT_VALUE - ) { - if (!isNestedInput) { + ): SummarySegment[] { + return block.inputList.flatMap((input) => { + const fields: SummarySegment[] = input.fieldRow + .filter((field) => { + if (!field.isVisible()) return false; + if (field instanceof FieldImage && field.isClickable()) { + return false; + } + return true; + }) + .map((field) => { + const text = field.getText() ?? field.getValue(); + // If the block is a full block field, we only want to know if it's an + // editable field if we're not directly on it. + if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) { inputCount++; + return {kind: 'input', text}; } - const targetBlock = input.connection.targetBlock(); - if (targetBlock) { - return [ - ...fields, - recursiveInputSummary(targetBlock as BlockSvg, true), - ]; + + return {kind: 'label', text}; + }); + + if ( + input.isVisible() && + input.connection && + input.connection.type === ConnectionType.INPUT_VALUE + ) { + if (!isNestedInput) { + inputCount++; + } + + const targetBlock = input.connection.targetBlock(); + if (targetBlock) { + const nestedSegments = recursiveInputSummary( + targetBlock as BlockSvg, + true, + ); + + if (!isNestedInput) { + // treat the whole nested summary as a single input segment + const nestedText = nestedSegments.map((s) => s.text).join(' '); + return [...fields, {kind: 'input', text: nestedText}]; } + + return [...fields, ...nestedSegments]; } - return fields; - }) - .join(' '); + } + + return fields; + }); + } + + const segments = recursiveInputSummary(block); + + const blockSummary = segments.map((s) => s.text).join(' '); + + const spokenParts: string[] = []; + let labelRun: string[] = []; + + // create runs of labels, flush when hitting an input + const flushLabels = () => { + if (!labelRun.length) return; + spokenParts.push(labelRun.join(' ')); + labelRun = []; + }; + + for (const seg of segments) { + if (seg.kind === 'label') { + labelRun.push(seg.text); + } else { + flushLabels(); + spokenParts.push(seg.text); + } } + flushLabels(); + + // comma-separate label runs and inputs + const commaSeparatedSummary = spokenParts.join(', '); - const blockSummary = recursiveInputSummary(block); return { blockSummary, + commaSeparatedSummary, inputCount, }; } From 93f849d0b86128359a233b2df5c9a53a5a64440e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 3 Dec 2025 10:16:31 -0800 Subject: [PATCH 31/51] fix: Add 'blocks' to toolbox category ARIA labels (#9504) --- core/toolbox/category.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index 7b0db7b3fcd..0d3c9382de6 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -307,6 +307,7 @@ export class ToolboxCategory if (className) { dom.addClass(toolboxLabel, className); } + aria.setState(toolboxLabel, aria.State.LABEL, `${name} blocks`); return toolboxLabel; } From 017a4ce9fd8520b8395e252bf0926b547e418a39 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 3 Dec 2025 10:46:38 -0800 Subject: [PATCH 32/51] fix: Don't include count of inputs in block ARIA label (#9502) * fix: Don't include count of inputs in block ARIA label * fix: Handle single input case --- core/block_svg.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index b90e101a17a..fa6c55160f2 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -244,9 +244,12 @@ export class BlockSvg private computeAriaLabel(): string { const {commaSeparatedSummary, inputCount} = buildBlockSummary(this); - const inputSummary = inputCount - ? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}` - : ''; + let inputSummary = ''; + if (inputCount > 1) { + inputSummary = 'has inputs'; + } else if (inputCount === 1) { + inputSummary = 'has input'; + } let blockTypeText = 'block'; if (this.isShadow()) { @@ -288,7 +291,7 @@ export class BlockSvg let additionalInfo = blockTypeText; if (inputSummary) { - additionalInfo = `${additionalInfo} with ${inputSummary}`; + additionalInfo = `${additionalInfo}, ${inputSummary}`; } return prefix + commaSeparatedSummary + ', ' + additionalInfo; From bbe6cc9b0e3defb5713de2959444d820108eba30 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 3 Dec 2025 15:10:15 -0800 Subject: [PATCH 33/51] fix: Improve ARIA region handling. (#9485) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9451 ### Proposed Changes Removes the ARIA `region` role for both flyouts and workspaces that are within mutators. ### Reason for Changes The use of the `region` role only adds confusion and slightly messes up region announcements for screen readers. `generic` has been used instead since it's the default container role (e.g. for `div`) and seems sufficient for what needs to be described in this case. Note that the delayed initialization for the flyout role is due to flyout initialization happening a bit later than its workspace DOM creation (so it doesn't seem possible to check for mutator status yet). There might be ways of doing this a bit more cleanly as part of #9307. ### Test Coverage No automated tests are needed for this experimental change. Manual testing comprised of navigating between the main workspace, the main workspace's toolbox and flyout, and a mutator workspace and flyout to validate that no unusual region readouts were happening. The accessibility node tree was also analyzed to verify that `generic` is correctly being applied as the role for the mutator workspace and flyout. ### Documentation No new documentation is needed for this experimental change. ### Additional Information This doesn't fully resolve all region issues, but it resolves the main ones (especially when combined with #9483 for NVDA). The main remaining problem at this point is that the main workspace itself is usually not read out as a region and it's not clear why. I suspect it has something to do with focus manager and how it automatically moves focus, but I'm not entirely sure what specific mechanism is causing the problem since both toolbox and flyout do something similar and don't have the same issue (flyout is particularly noteworthy since it's a workspace in itself). There may be some other focus oddities happening to cause the difference but, for now, this seems reasonable. If testing or user feedback find that the lack of consistent region readout is problematic for the main workspace then a new issue can be opened and investigated separately. --- core/flyout_base.ts | 3 +++ core/utils/aria.ts | 1 + core/workspace_svg.ts | 7 ++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 7caf98c9b2d..8d4264f4608 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -338,6 +338,9 @@ export abstract class Flyout init(targetWorkspace: WorkspaceSvg) { this.targetWorkspace = targetWorkspace; this.workspace_.targetWorkspace = targetWorkspace; + if (this.targetWorkspace.isMutator) { + aria.setRole(this.workspace_.getFocusableElement(), aria.Role.GENERIC); + } this.workspace_.scrollbar = new ScrollbarPair( this.workspace_, diff --git a/core/utils/aria.ts b/core/utils/aria.ts index 84d4e2312c4..64d1bf143c6 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -54,6 +54,7 @@ export enum Role { COMBOBOX = 'combobox', SPINBUTTON = 'spinbutton', REGION = 'region', + GENERIC = 'generic', } /** diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index e09618bb9d8..b919fa5c7cc 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -762,14 +762,19 @@ export class WorkspaceSvg }); let ariaLabel = null; + let role: aria.Role | null = null; if (this.isFlyout) { ariaLabel = 'Flyout'; + // Default to region, but this may change during flyout initialization. + role = aria.Role.REGION; } else if (this.isMutator) { ariaLabel = 'Mutator Workspace'; + role = aria.Role.GENERIC; } else { ariaLabel = Msg['WORKSPACE_ARIA_LABEL']; + role = aria.Role.REGION; } - aria.setRole(this.svgGroup_, aria.Role.REGION); + aria.setRole(this.svgGroup_, role); aria.setState(this.svgGroup_, aria.State.LABEL, ariaLabel); // Note that a alone does not receive mouse events--it must have a From 74e81ceb869b6c0a275ec53e5d8f00022a576526 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 3 Dec 2025 15:11:53 -0800 Subject: [PATCH 34/51] feat: Add labels for Toolbox and Flyout. (#9483) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9464 ### Proposed Changes Adds ARIA labels for `Toolbox` and `Flyout` ('s `Workspace`) containers to complement their existing region labels. ### Reason for Changes In some cases certain screen readers (like NVDA) don't read out region labels. Previously, only the region labels provided context for currently being within the toolbox or flyout, so adding additional labels on the ARIA `tree` containers themselves ensures that these contexts are always read if the tree's context is read. This adds redundancy with the region output if both are read, but ChromeVox at least adds some delay between finishing the tree's context and beginning the region's context and this seems to mesh well together. The fully read context now feels like: "here's an item, it's the toolbox tree, and that tree is within the toolbox region." Even though both regions only contain a single tree it doesn't feel dimorphic. ### Test Coverage No new automated tests are needed for this experimental change. This has been manually tested with ChromeVox to demonstrate the dual tree + region output for both toolbox and flyout: [Screen recording 2025-11-26 2.52.22 PM.webm](https://github.com/user-attachments/assets/aa43ae81-9da6-4c79-b0fc-120146892aae) ### Documentation No new documentation is needed for this experimental change. ### Additional Information This was only tested on ChromeVox. --- core/toolbox/toolbox.ts | 5 +++++ core/workspace_svg.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index e03b09a37f0..dd3c0db6be8 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -226,6 +226,11 @@ export class Toolbox contentsContainer.style.flexDirection = 'row'; } aria.setRole(contentsContainer, aria.Role.TREE); + aria.setState( + contentsContainer, + aria.State.LABEL, + Msg['TOOLBOX_ARIA_LABEL'], + ); return contentsContainer; } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index b919fa5c7cc..1f16f24c6e0 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -806,6 +806,7 @@ export class WorkspaceSvg if (this.isFlyout) { // Use the block canvas as the primary tree parent for flyout blocks. aria.setRole(this.svgBlockCanvas_, aria.Role.TREE); + aria.setState(this.svgBlockCanvas_, aria.State.LABEL, ariaLabel); } else { browserEvents.conditionalBind( this.svgGroup_, From 80660bdf71b34ea99164fb6bc644ad5111206367 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 3 Dec 2025 15:51:30 -0800 Subject: [PATCH 35/51] feat: Add verbosity shortcuts (experimental) (#9481) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes part of https://github.com/RaspberryPiFoundation/blockly-keyboard-experimentation/issues/764 Fixes part of #9450 (infrastructure needs) ### Proposed Changes Introduces support for two new "where am I?" shortcuts for helping to provide location context for users: - `I`: re-reads the current selected block with full verbosity (i.e. also includes the block's field types with their values in the readout). - `shift+I`: reads the current selected block's parent with full verbosity. Note that this includes some functional changes to `Field` to allow for more powerful customization of a field's ARIA representation (by splitting up value and type), though a field's value defaults potentially to null which will be ignored in the final ARIA computed label. This seems necessary per the discussion here: https://github.com/RaspberryPiFoundation/blockly/pull/9470/files#r2541508565 but more consideration may be needed here as part of #9307. Some limitations in the new shortcuts: - They will not read out anything if a block is not selected (e.g. for fields and icons). - They read out input blocks when the input block is selected. - They cannot read out anything while in move mode (due to the behavior here in the plugin which automatically cancels moves if an unknown shortcut is pressed: https://github.com/RaspberryPiFoundation/blockly-keyboard-experimentation/blob/a36f3662b05c2ddcd18bde8745777fff8dc3df31/src/actions/mover.ts#L166-L191). - The readout is limited by the problems of dynamic ARIA announcements (per #9460). ### Reason for Changes https://github.com/RaspberryPiFoundation/blockly-keyboard-experimentation/issues/764 provides context on the specific needs addressed here. ### Test Coverage Self tested. No new automated tests needed for experimental work. ### Documentation No new documentation needed for experimental work. ### Additional Information This was spun out of #9470 with the intent of getting shortcuts initially working checked in even if the entirety of the experience is incomplete. --- blocks/math.ts | 4 +-- core/block_svg.ts | 11 ++++--- core/field.ts | 65 +++++++++++++++++++++++++++++++++++++-- core/field_checkbox.ts | 10 +++++- core/field_dropdown.ts | 13 +++----- core/field_image.ts | 10 +++--- core/field_input.ts | 7 +---- core/shortcut_items.ts | 70 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 161 insertions(+), 29 deletions(-) diff --git a/blocks/math.ts b/blocks/math.ts index b756967832e..9ac84fc0c68 100644 --- a/blocks/math.ts +++ b/blocks/math.ts @@ -32,7 +32,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([ 'type': 'field_number', 'name': 'NUM', 'value': 0, - 'ariaName': 'Number', + 'ariaTypeName': 'Number', }, ], 'output': 'Number', @@ -55,7 +55,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([ { 'type': 'field_dropdown', 'name': 'OP', - 'ariaName': 'Arithmetic operation', + 'ariaTypeName': 'Arithmetic operation', 'options': [ ['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD', 'Plus'], ['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS', 'Minus'], diff --git a/core/block_svg.ts b/core/block_svg.ts index fa6c55160f2..6de49e8ac0a 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -242,8 +242,11 @@ export class BlockSvg ); } - private computeAriaLabel(): string { - const {commaSeparatedSummary, inputCount} = buildBlockSummary(this); + computeAriaLabel(verbose: boolean = false): string { + const {commaSeparatedSummary, inputCount} = buildBlockSummary( + this, + verbose, + ); let inputSummary = ''; if (inputCount > 1) { inputSummary = 'has inputs'; @@ -2029,7 +2032,7 @@ interface BlockSummary { inputCount: number; } -function buildBlockSummary(block: BlockSvg): BlockSummary { +function buildBlockSummary(block: BlockSvg, verbose: boolean): BlockSummary { let inputCount = 0; // Produce structured segments @@ -2059,7 +2062,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary { return true; }) .map((field) => { - const text = field.getText() ?? field.getValue(); + const text = field.computeAriaLabel(verbose); // If the block is a full block field, we only want to know if it's an // editable field if we're not directly on it. if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) { diff --git a/core/field.ts b/core/field.ts index 3bb3d5caf40..60b1ea17868 100644 --- a/core/field.ts +++ b/core/field.ts @@ -271,8 +271,67 @@ export abstract class Field } } - getAriaName(): string | null { - return this.config?.ariaName ?? null; + /** + * Gets a an ARIA-friendly label representation of this field's type. + * + * @returns An ARIA representation of the field's type or null if it is + * unspecified. + */ + getAriaTypeName(): string | null { + return this.config?.ariaTypeName ?? null; + } + + /** + * Gets a an ARIA-friendly label representation of this field's value. + * + * Note that implementations should generally always override this value to + * ensure a non-null value is returned since the default implementation relies + * on 'getValue' which may return null, and a null return value for this + * function will prompt ARIA label generation to skip the field's value + * entirely when there may be a better contextual placeholder to use, instead, + * specific to the field. + * + * @returns An ARIA representation of the field's value, or null if no value + * is currently defined or known for the field. + */ + getAriaValue(): string | null { + const currentValue = this.getValue(); + return currentValue !== null ? String(currentValue) : null; + } + + /** + * Computes a descriptive ARIA label to represent this field with configurable + * verbosity. + * + * A 'verbose' label includes type information, if available, whereas a + * non-verbose label only contains the field's value. + * + * Note that this will always return the latest representation of the field's + * label which may differ from any previously set ARIA label for the field + * itself. Implementations are largely responsible for ensuring that the + * field's ARIA label is set correctly at relevant moments in the field's + * lifecycle (such as when its value changes). + * + * Finally, it is never guaranteed that implementations use the label returned + * by this method for their actual ARIA label. Some implementations may rely + * on other context to convey information like the field's value. Example: + * checkboxes represent their checked/non-checked status (i.e. value) through + * a separate ARIA property. + * + * It's possible this returns an empty string if the field doesn't supply type + * or value information for certain cases (such as a null value). This will + * lead to the field being potentially COMPLETELY HIDDEN for screen reader + * navigation. + * + * @param verbose Whether to include the field's type information in the + * returned label, if available. + */ + computeAriaLabel(verbose: boolean = false): string { + const components: Array = [this.getAriaValue()]; + if (verbose) { + components.push(this.getAriaTypeName()); + } + return components.filter((item) => item !== null).join(', '); } /** @@ -1426,7 +1485,7 @@ export interface FieldConfig { type: string; name?: string; tooltip?: string; - ariaName?: string; + ariaTypeName?: string; } /** diff --git a/core/field_checkbox.ts b/core/field_checkbox.ts index df07168a7a2..aecead2e80c 100644 --- a/core/field_checkbox.ts +++ b/core/field_checkbox.ts @@ -116,10 +116,18 @@ export class FieldCheckbox extends Field { this.recomputeAria(); } + override getAriaValue(): string { + return this.value_ ? 'checked' : 'not checked'; + } + private recomputeAria() { const element = this.getFocusableElement(); aria.setRole(element, aria.Role.CHECKBOX); - aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Checkbox'); + aria.setState( + element, + aria.State.LABEL, + this.getAriaTypeName() ?? 'Checkbox', + ); aria.setState(element, aria.State.CHECKED, !!this.value_); } diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 8cefd9c825f..de0955b9331 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -202,6 +202,10 @@ export class FieldDropdown extends Field { this.recomputeAria(); } + override getAriaValue(): string { + return this.computeLabelForOption(this.selectedOption); + } + protected recomputeAria() { if (!this.fieldGroup_) return; // There's no element to set currently. const element = this.getFocusableElement(); @@ -214,14 +218,7 @@ export class FieldDropdown extends Field { aria.clearState(element, aria.State.CONTROLS); } - const label = [ - this.computeLabelForOption(this.selectedOption), - this.getAriaName(), - ] - .filter((item) => !!item) - .join(', '); - - aria.setState(element, aria.State.LABEL, label); + aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true)); } /** diff --git a/core/field_image.ts b/core/field_image.ts index 2b5a3139c71..b7aaf5e06bf 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -132,6 +132,10 @@ export class FieldImage extends Field { } } + override getAriaValue(): string { + return this.altText; + } + /** * Create the block UI for this image. */ @@ -159,11 +163,7 @@ export class FieldImage extends Field { if (this.isClickable()) { this.imageElement.style.cursor = 'pointer'; aria.setRole(element, aria.Role.BUTTON); - - const label = [this.altText, this.getAriaName()] - .filter((item) => !!item) - .join(', '); - aria.setState(element, aria.State.LABEL, label); + aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true)); } else { // The field isn't navigable unless it's clickable. aria.setRole(element, aria.Role.PRESENTATION); diff --git a/core/field_input.ts b/core/field_input.ts index 696e2307986..7132d9ab16a 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -188,13 +188,8 @@ export abstract class FieldInput extends Field< */ protected recomputeAriaLabel() { if (!this.fieldGroup_) return; - const element = this.getFocusableElement(); - const label = [this.getValue(), this.getAriaName()] - .filter((item) => item !== null) - .join(', '); - - aria.setState(element, aria.State.LABEL, label); + aria.setState(element, aria.State.LABEL, super.computeAriaLabel()); } override isFullBlockField(): boolean { diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index f8c95500770..c865b618051 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -16,6 +16,7 @@ import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; import {isDraggable} from './interfaces/i_draggable.js'; import {IFocusableNode} from './interfaces/i_focusable_node.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; +import {aria} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import {KeyCodes} from './utils/keycodes.js'; import {Rect} from './utils/rect.js'; @@ -33,6 +34,8 @@ export enum names { PASTE = 'paste', UNDO = 'undo', REDO = 'redo', + READ_FULL_BLOCK_SUMMARY = 'read_full_block_summary', + READ_BLOCK_PARENT_SUMMARY = 'read_block_parent_summary', } /** @@ -386,6 +389,71 @@ export function registerRedo() { ShortcutRegistry.registry.register(redoShortcut); } +/** + * Registers a keyboard shortcut for re-reading the current selected block's + * summary with additional verbosity to help provide context on where the user + * is currently navigated (for screen reader users only). + */ +export function registerReadFullBlockSummary() { + const i = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, null); + const readFullBlockSummaryShortcut: KeyboardShortcut = { + name: names.READ_FULL_BLOCK_SUMMARY, + preconditionFn(workspace) { + return ( + !workspace.isDragging() && + !getFocusManager().ephemeralFocusTaken() && + !!getFocusManager().getFocusedNode() && + getFocusManager().getFocusedNode() instanceof BlockSvg + ); + }, + callback(_, e) { + const selectedBlock = getFocusManager().getFocusedNode() as BlockSvg; + const blockSummary = selectedBlock.computeAriaLabel(true); + aria.announceDynamicAriaState(`Current block: ${blockSummary}`); + e.preventDefault(); + return true; + }, + keyCodes: [i], + }; + ShortcutRegistry.registry.register(readFullBlockSummaryShortcut); +} + +/** + * Registers a keyboard shortcut for re-reading the current selected block's + * parent block summary with additional verbosity to help provide context on + * where the user is currently navigated (for screen reader users only). + */ +export function registerReadBlockParentSummary() { + const shiftI = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, [ + KeyCodes.SHIFT, + ]); + const readBlockParentSummaryShortcut: KeyboardShortcut = { + name: names.READ_BLOCK_PARENT_SUMMARY, + preconditionFn(workspace) { + return ( + !workspace.isDragging() && + !getFocusManager().ephemeralFocusTaken() && + !!getFocusManager().getFocusedNode() && + getFocusManager().getFocusedNode() instanceof BlockSvg + ); + }, + callback(_, e) { + const selectedBlock = getFocusManager().getFocusedNode() as BlockSvg; + const parentBlock = selectedBlock.getParent(); + if (parentBlock) { + const blockSummary = parentBlock.computeAriaLabel(true); + aria.announceDynamicAriaState(`Parent block: ${blockSummary}`); + } else { + aria.announceDynamicAriaState('Current block has no parent'); + } + e.preventDefault(); + return true; + }, + keyCodes: [shiftI], + }; + ShortcutRegistry.registry.register(readBlockParentSummaryShortcut); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -400,6 +468,8 @@ export function registerDefaultShortcuts() { registerPaste(); registerUndo(); registerRedo(); + registerReadFullBlockSummary(); + registerReadBlockParentSummary(); } registerDefaultShortcuts(); From 0506479b5c3d2aa0a706b354b60a7e916216c583 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 4 Dec 2025 08:25:57 -0800 Subject: [PATCH 36/51] fix: Refer to connections as positions in ARIA descriptions (#9509) --- core/rendered_connection.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index bbf32006bc8..7cb8fe1972d 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -335,7 +335,10 @@ export class RenderedConnection if (highlightSvg) { highlightSvg.style.display = ''; aria.setRole(highlightSvg, aria.Role.FIGURE); - aria.setState(highlightSvg, aria.State.ROLEDESCRIPTION, 'Connection'); + const connectionType = + this.type === ConnectionType.INPUT_VALUE ? 'value' : 'statement'; + const roleDescription = `${connectionType} block position`; + aria.setState(highlightSvg, aria.State.ROLEDESCRIPTION, roleDescription); if (this.type === ConnectionType.NEXT_STATEMENT) { const parentInput = this.getParentInput() ?? @@ -359,7 +362,7 @@ export class RenderedConnection `${this.getParentInput()?.getFieldRowLabel()}`, ); } else { - aria.setState(highlightSvg, aria.State.LABEL, 'Open connection'); + aria.setState(highlightSvg, aria.State.LABEL, 'Empty'); } } } From af2f0b400653c8c23d72c0800da9b5f3eff1cd0f Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 4 Dec 2025 09:02:42 -0800 Subject: [PATCH 37/51] fix: Don't include 'begin stack' in block ARIA labels in the flyout (#9508) * feat: Use custom ARIA roledescriptions for different block types * fix: Don't include 'begin stack' in block ARIA labels in the flyout * fix: Fix visibility of `computeAriaLabel` --- core/block_svg.ts | 81 +++++++++++++++++++++++++---------------------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 6de49e8ac0a..f40f661cb74 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -243,76 +243,81 @@ export class BlockSvg } computeAriaLabel(verbose: boolean = false): string { - const {commaSeparatedSummary, inputCount} = buildBlockSummary( - this, - verbose, - ); - let inputSummary = ''; - if (inputCount > 1) { - inputSummary = 'has inputs'; - } else if (inputCount === 1) { - inputSummary = 'has input'; - } + const labelComponents = []; - let blockTypeText = 'block'; - if (this.isShadow()) { - blockTypeText = 'replaceable block'; - } else if (this.outputConnection) { - blockTypeText = 'input block'; - } else if (this.statementInputCount) { - blockTypeText = 'C-shaped block'; + if (!this.workspace.isFlyout && this.getRootBlock() === this) { + labelComponents.push('Begin stack'); } - const modifiers = []; - if (!this.isEnabled()) { - modifiers.push('disabled'); - } - if (this.isCollapsed()) { - modifiers.push('collapsed'); - } - if (modifiers.length) { - blockTypeText = `${modifiers.join(' ')} ${blockTypeText}`; - } - - let prefix = ''; const parentInput = ( this.previousConnection ?? this.outputConnection )?.targetConnection?.getParentInput(); if (parentInput && parentInput.type === inputTypes.STATEMENT) { - prefix = `Begin ${parentInput.getFieldRowLabel()}, `; + labelComponents.push(`Begin ${parentInput.getFieldRowLabel()}`); } else if ( parentInput && parentInput.type === inputTypes.VALUE && this.getParent()?.statementInputCount ) { - prefix = `${parentInput.getFieldRowLabel()} `; + labelComponents.push(`${parentInput.getFieldRowLabel()}`); } - if (this.getRootBlock() === this) { - prefix = 'Begin stack, ' + prefix; + const {commaSeparatedSummary, inputCount} = buildBlockSummary( + this, + verbose, + ); + labelComponents.push(commaSeparatedSummary); + + if (!this.isEnabled()) { + labelComponents.push('disabled'); + } + if (this.isCollapsed()) { + labelComponents.push('collapsed'); } - let additionalInfo = blockTypeText; - if (inputSummary) { - additionalInfo = `${additionalInfo}, ${inputSummary}`; + if (inputCount > 1) { + labelComponents.push('has inputs'); + } else if (inputCount === 1) { + labelComponents.push('has input'); } - return prefix + commaSeparatedSummary + ', ' + additionalInfo; + return labelComponents.join(', '); } private computeAriaRole() { if (this.workspace.isFlyout) { aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM); } else { + const roleDescription = this.getAriaRoleDescription(); aria.setState( this.pathObject.svgPath, aria.State.ROLEDESCRIPTION, - 'block', + roleDescription, ); aria.setRole(this.pathObject.svgPath, aria.Role.FIGURE); } } + /** + * Returns the ARIA role description for this block. + * + * Block definitions may override this method via a mixin to customize + * their role description. + * + * @returns The ARIA roledescription for this block. + */ + protected getAriaRoleDescription() { + if (this.isShadow()) { + return 'replaceable block'; + } else if (this.outputConnection) { + return 'value block'; + } else if (this.statementInputCount) { + return 'container block'; + } else { + return 'statement block'; + } + } + /** * Create and initialize the SVG representation of the block. * May be called more than once. From ff6ca2e74390782704ca2a9811e6d902045030d5 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 5 Dec 2025 13:27:55 -0800 Subject: [PATCH 38/51] fix: Refer to empty statement connections as ending rather than beginning a clause (#9512) --- core/rendered_connection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index 7cb8fe1972d..f824cdb38f9 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -349,7 +349,7 @@ export class RenderedConnection aria.setState( highlightSvg, aria.State.LABEL, - `${this.getParentInput() ? 'Begin' : 'End'} ${parentInput.getFieldRowLabel()}`, + `End ${parentInput.getFieldRowLabel()}`, ); } } else if ( From f35f4d8aec70aedc38ec814fc69f30aa84bde9e7 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 5 Dec 2025 13:28:37 -0800 Subject: [PATCH 39/51] feat: Use custom ARIA roledescriptions for different block types (#9507) * feat: Use custom ARIA roledescriptions for different block types * fix: Denote shadow blocks in the label rather than role description --- core/block_svg.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index f40f661cb74..1a263f70289 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -274,6 +274,9 @@ export class BlockSvg if (this.isCollapsed()) { labelComponents.push('collapsed'); } + if (this.isShadow()) { + labelComponents.push('replaceable'); + } if (inputCount > 1) { labelComponents.push('has inputs'); @@ -307,9 +310,7 @@ export class BlockSvg * @returns The ARIA roledescription for this block. */ protected getAriaRoleDescription() { - if (this.isShadow()) { - return 'replaceable block'; - } else if (this.outputConnection) { + if (this.outputConnection) { return 'value block'; } else if (this.statementInputCount) { return 'container block'; From 8522bfff6156a36490913a1326d0bdb74b5b544d Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 5 Dec 2025 14:25:44 -0800 Subject: [PATCH 40/51] feat: Make navigation looping configurable (#9511) * feat: Make navigation looping configurable * chore: Add TODO to clean up API --- core/keyboard_nav/block_navigation_policy.ts | 10 +++- core/keyboard_nav/line_cursor.ts | 63 ++++++++++++++++---- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index f79efcf2529..3449c73f534 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -181,7 +181,8 @@ function getBlockNavigationCandidates( * `delta` relative to the current element's stack when navigating backwards. */ export function navigateStacks(current: ISelectable, delta: number) { - const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg) + const workspace = current.workspace as WorkspaceSvg; + const stacks: IFocusableNode[] = workspace .getTopBoundedElements(true) .filter((element: IBoundedElement) => isFocusableNode(element)); const currentIndex = stacks.indexOf( @@ -189,12 +190,15 @@ export function navigateStacks(current: ISelectable, delta: number) { ); const targetIndex = currentIndex + delta; let result: IFocusableNode | null = null; + const loop = workspace.getCursor().getNavigationLoops(); if (targetIndex >= 0 && targetIndex < stacks.length) { result = stacks[targetIndex]; - } else if (targetIndex < 0) { + } else if (loop && targetIndex < 0) { result = stacks[stacks.length - 1]; - } else if (targetIndex >= stacks.length) { + } else if (loop && targetIndex >= stacks.length) { result = stacks[0]; + } else { + return null; } // When navigating to a previous block stack, our previous sibling is the last diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 549e51a9352..fe2779e48a9 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -42,6 +42,9 @@ export class LineCursor extends Marker { /** Locations to try moving the cursor to after a deletion. */ private potentialNodes: IFocusableNode[] | null = null; + /** Whether or not navigation loops around when reaching the end. */ + private navigationLoops = true; + /** * @param workspace The workspace this cursor belongs to. */ @@ -64,7 +67,7 @@ export class LineCursor extends Marker { const newNode = this.getNextNode( curNode, this.getValidationFunction(NavigationDirection.NEXT), - true, + this.getNavigationLoops(), ); if (newNode) { @@ -89,7 +92,7 @@ export class LineCursor extends Marker { const newNode = this.getNextNode( curNode, this.getValidationFunction(NavigationDirection.IN), - true, + this.getNavigationLoops(), ); if (newNode) { @@ -112,7 +115,7 @@ export class LineCursor extends Marker { const newNode = this.getPreviousNode( curNode, this.getValidationFunction(NavigationDirection.PREVIOUS), - true, + this.getNavigationLoops(), ); if (newNode) { @@ -137,7 +140,7 @@ export class LineCursor extends Marker { const newNode = this.getPreviousNode( curNode, this.getValidationFunction(NavigationDirection.OUT), - true, + this.getNavigationLoops(), ); if (newNode) { @@ -158,12 +161,12 @@ export class LineCursor extends Marker { const inNode = this.getNextNode( curNode, this.getValidationFunction(NavigationDirection.IN), - true, + this.getNavigationLoops(), ); const nextNode = this.getNextNode( curNode, this.getValidationFunction(NavigationDirection.NEXT), - true, + this.getNavigationLoops(), ); return inNode === nextNode; @@ -219,11 +222,22 @@ export class LineCursor extends Marker { getNextNode( node: IFocusableNode | null, isValid: (p1: IFocusableNode | null) => boolean, + // TODO: Consider deprecating and removing this argument. loop: boolean, ): IFocusableNode | null { - if (!node || (!loop && this.getLastNode() === node)) return null; + const originalLoop = this.getNavigationLoops(); + this.setNavigationLoops(loop); + + let result: IFocusableNode | null; + if (!node || (!loop && this.getLastNode() === node)) { + result = null; + } else { + result = this.getNextNodeImpl(node, isValid); + } - return this.getNextNodeImpl(node, isValid); + this.setNavigationLoops(originalLoop); + + return result; } /** @@ -273,11 +287,22 @@ export class LineCursor extends Marker { getPreviousNode( node: IFocusableNode | null, isValid: (p1: IFocusableNode | null) => boolean, + // TODO: Consider deprecating and removing this argument. loop: boolean, ): IFocusableNode | null { - if (!node || (!loop && this.getFirstNode() === node)) return null; + const originalLoop = this.getNavigationLoops(); + this.setNavigationLoops(loop); + + let result: IFocusableNode | null; + if (!node || (!loop && this.getFirstNode() === node)) { + result = null; + } else { + result = this.getPreviousNodeImpl(node, isValid); + } - return this.getPreviousNodeImpl(node, isValid); + this.setNavigationLoops(originalLoop); + + return result; } /** @@ -538,6 +563,24 @@ export class LineCursor extends Marker { const first = this.getFirstNode(); return this.getPreviousNode(first, () => true, true); } + + /** + * Sets whether or not navigation should loop around when reaching the end + * of the workspace. + * + * @param loops True if navigation should loop around, otherwise false. + */ + setNavigationLoops(loops: boolean) { + this.navigationLoops = loops; + } + + /** + * Returns whether or not navigation loops around when reaching the end of + * the workspace. + */ + getNavigationLoops(): boolean { + return this.navigationLoops; + } } registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor); From 57be8ca37fd026b2dc512a09db7d8e774bb34a22 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 8 Dec 2025 08:20:09 -0800 Subject: [PATCH 41/51] feat: Add support for playing beeps (#9518) * feat: Add support for playing beeps * chore: Fix typo --- core/workspace_audio.ts | 61 +++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/core/workspace_audio.ts b/core/workspace_audio.ts index 1759b30edbb..ff70c2776aa 100644 --- a/core/workspace_audio.ts +++ b/core/workspace_audio.ts @@ -124,20 +124,11 @@ export class WorkspaceAudio { * @param opt_volume Volume of sound (0-1). */ play(name: string, opt_volume?: number) { - if (this.muted) { - return; - } + if (!this.isPlayingAllowed()) return; + const sound = this.sounds.get(name); if (sound) { - // Don't play one sound on top of another. - const now = new Date(); - if ( - this.lastSound !== null && - now.getTime() - this.lastSound.getTime() < SOUND_LIMIT - ) { - return; - } - this.lastSound = now; + this.lastSound = new Date(); let mySound; if (userAgent.IPAD || userAgent.ANDROID) { // Creating a new audio node causes lag in Android and iPad. Android @@ -168,4 +159,50 @@ export class WorkspaceAudio { getMuted(): boolean { return this.muted; } + + /** + * Returns whether or not playing sounds is currently allowed. + * + * @returns False if audio is muted or a sound has just been played, otherwise + * true. + */ + private isPlayingAllowed() { + const now = new Date(); + + if ( + this.getMuted() || + (this.lastSound !== null && + now.getTime() - this.lastSound.getTime() < SOUND_LIMIT) + ) { + return false; + } + return true; + } + + /** + * Plays a brief beep at the given frequency. + * + * @param tone The frequency of the beep to play. + */ + beep(tone: number) { + if (!this.isPlayingAllowed()) return; + this.lastSound = new Date(); + + const context = new AudioContext(); + + const oscillator = context.createOscillator(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(tone, context.currentTime); + + const gainNode = context.createGain(); + gainNode.gain.setValueAtTime(0, context.currentTime); + gainNode.gain.linearRampToValueAtTime(0.5, context.currentTime + 0.01); // Fade in + gainNode.gain.linearRampToValueAtTime(0, context.currentTime + 0.2); // Fade out + + oscillator.connect(gainNode); + gainNode.connect(context.destination); + + oscillator.start(context.currentTime); + oscillator.stop(context.currentTime + 0.2); + } } From ebda1e4b730e72a731c244aa963ff864a94989dd Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 8 Dec 2025 10:29:40 -0800 Subject: [PATCH 42/51] fix: Don't include 'begin' in label for blocks in the first statement input (#9514) --- core/block_svg.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 1a263f70289..8daea9ae81f 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -252,7 +252,15 @@ export class BlockSvg const parentInput = ( this.previousConnection ?? this.outputConnection )?.targetConnection?.getParentInput(); - if (parentInput && parentInput.type === inputTypes.STATEMENT) { + if ( + parentInput && + parentInput.type === inputTypes.STATEMENT && + // The first statement input is redundant with the parent block's label. + parentInput !== + parentInput + .getSourceBlock() + .inputList.find((input) => input.type === inputTypes.STATEMENT) + ) { labelComponents.push(`Begin ${parentInput.getFieldRowLabel()}`); } else if ( parentInput && From 115f45f18d86cda6a17e0d7919973ec06c981df8 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Mon, 8 Dec 2025 15:46:15 -0500 Subject: [PATCH 43/51] fix: enable i shortcuts for items on blocks (#9519) --- core/shortcut_items.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index c865b618051..499c6b5fd4d 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -393,9 +393,11 @@ export function registerRedo() { * Registers a keyboard shortcut for re-reading the current selected block's * summary with additional verbosity to help provide context on where the user * is currently navigated (for screen reader users only). + * + * This works when a block is selected, or some other part of a block + * such as a field or icon. */ export function registerReadFullBlockSummary() { - const i = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, null); const readFullBlockSummaryShortcut: KeyboardShortcut = { name: names.READ_FULL_BLOCK_SUMMARY, preconditionFn(workspace) { @@ -403,17 +405,19 @@ export function registerReadFullBlockSummary() { !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() && !!getFocusManager().getFocusedNode() && - getFocusManager().getFocusedNode() instanceof BlockSvg + // Either a block or something that has a parent block is focused + !!workspace.getCursor().getSourceBlock() ); }, - callback(_, e) { - const selectedBlock = getFocusManager().getFocusedNode() as BlockSvg; + callback(workspace, e) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; const blockSummary = selectedBlock.computeAriaLabel(true); aria.announceDynamicAriaState(`Current block: ${blockSummary}`); e.preventDefault(); return true; }, - keyCodes: [i], + keyCodes: [KeyCodes.I], }; ShortcutRegistry.registry.register(readFullBlockSummaryShortcut); } @@ -434,11 +438,13 @@ export function registerReadBlockParentSummary() { !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() && !!getFocusManager().getFocusedNode() && - getFocusManager().getFocusedNode() instanceof BlockSvg + // Either a block or something that has a parent block is focused + !!workspace.getCursor().getSourceBlock() ); }, - callback(_, e) { - const selectedBlock = getFocusManager().getFocusedNode() as BlockSvg; + callback(workspace, e) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; const parentBlock = selectedBlock.getParent(); if (parentBlock) { const blockSummary = parentBlock.computeAriaLabel(true); From d57e66ed8f541cd149c6c62bbf9c2701e555ed21 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 8 Dec 2025 14:00:40 -0800 Subject: [PATCH 44/51] fix: Make flyout navigation respect the cursor's looping setting (#9520) --- core/keyboard_nav/flyout_navigation_policy.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/keyboard_nav/flyout_navigation_policy.ts b/core/keyboard_nav/flyout_navigation_policy.ts index 6552c27b499..34ac9f26e26 100644 --- a/core/keyboard_nav/flyout_navigation_policy.ts +++ b/core/keyboard_nav/flyout_navigation_policy.ts @@ -60,6 +60,9 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { if (index === -1) return null; index++; if (index >= flyoutContents.length) { + if (!this.flyout.getWorkspace().getCursor().getNavigationLoops()) { + return null; + } index = 0; } @@ -83,6 +86,9 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { if (index === -1) return null; index--; if (index < 0) { + if (!this.flyout.getWorkspace().getCursor().getNavigationLoops()) { + return null; + } index = flyoutContents.length - 1; } From 40e9ac8e004ca21ca569820de0be967d836a09cc Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 9 Dec 2025 11:44:36 -0800 Subject: [PATCH 45/51] feat: Beep when navigating across nesting levels (#9521) --- core/block_svg.ts | 16 ++++++++++++++++ core/keyboard_nav/line_cursor.ts | 11 +++++++++++ 2 files changed, 27 insertions(+) diff --git a/core/block_svg.ts b/core/block_svg.ts index 8daea9ae81f..e0eaf90b99f 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -1990,6 +1990,22 @@ export class BlockSvg return true; } + /** + * Returns how deeply nested this block is in parent C-shaped blocks. + * + * @internal + * @returns The nesting level of this block, starting at 0 for root blocks. + */ + getNestingLevel(): number { + // Don't consider value blocks to be nested. + if (this.outputConnection) { + return this.getParent()?.getNestingLevel() ?? 0; + } + + const surroundParent = this.getSurroundParent(); + return surroundParent ? surroundParent.getNestingLevel() + 1 : 0; + } + /** * Announces the current dynamic state of the specified block, if any. * diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index fe2779e48a9..c0d76fa5aae 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -542,6 +542,17 @@ export class LineCursor extends Marker { * @param newNode The new location of the cursor. */ setCurNode(newNode: IFocusableNode) { + const oldBlock = this.getSourceBlock(); + const newBlock = this.getSourceBlockFromNode(newNode); + if ( + oldBlock && + newBlock && + oldBlock.getNestingLevel() !== newBlock.getNestingLevel() + ) { + newBlock.workspace + .getAudioManager() + .beep(400 + newBlock.getNestingLevel() * 40); + } getFocusManager().focusNode(newNode); } From 4622cf538d3ed47ac3dc4b7165c61fe45f220c25 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 10 Dec 2025 10:58:42 -0800 Subject: [PATCH 46/51] feat: Include parent category in verbose block descriptions (#9522) * feat: Include parent category in verbose block descriptions * fix: Loosen typechecking to break import cycle * chore: Make the linter happy --- core/block_svg.ts | 46 ++++++++++++++++++++++++++++++++++++++++ core/toolbox/category.ts | 9 ++++++++ 2 files changed, 55 insertions(+) diff --git a/core/block_svg.ts b/core/block_svg.ts index e0eaf90b99f..107a1dade02 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -56,6 +56,7 @@ import {RenderedConnection} from './rendered_connection.js'; import type {IPathObject} from './renderers/common/i_path_object.js'; import * as blocks from './serialization/blocks.js'; import type {BlockStyle} from './theme.js'; +import type {ToolboxCategory} from './toolbox/category.js'; import * as Tooltip from './tooltip.js'; import {idGenerator} from './utils.js'; import * as aria from './utils/aria.js'; @@ -2157,6 +2158,51 @@ function buildBlockSummary(block: BlockSvg, verbose: boolean): BlockSummary { } flushLabels(); + if (verbose) { + const toolbox = block.workspace.getToolbox(); + let parentCategory: ToolboxCategory | undefined = undefined; + let colourMatchingCategory: ToolboxCategory | undefined = undefined; + if ( + toolbox && + 'getToolboxItems' in toolbox && + typeof toolbox.getToolboxItems === 'function' + ) { + for (const category of toolbox.getToolboxItems()) { + if ( + !( + 'getColour' in category && + typeof category.getColour === 'function' && + 'getContents' in category && + typeof category.getContents === 'function' + ) + ) { + continue; + } + if (category.getColour() === block.getColour()) { + colourMatchingCategory = category; + } + const contents = category.getContents(); + if (!Array.isArray(contents)) break; + for (const item of contents) { + if ( + item.kind.toLowerCase() === 'block' && + 'type' in item && + item.type === block.type + ) { + parentCategory = category; + break; + } + } + if (parentCategory) break; + } + } + if (parentCategory || colourMatchingCategory) { + spokenParts.push( + `${(parentCategory ?? colourMatchingCategory)?.getName()} category`, + ); + } + } + // comma-separate label runs and inputs const commaSeparatedSummary = spokenParts.join(', '); diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index 0d3c9382de6..60694aa8073 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -381,6 +381,15 @@ export class ToolboxCategory return ''; } + /** + * Returns the colour of this category. + * + * @internal + */ + getColour() { + return this.colour_; + } + /** * Gets the HTML element that is clickable. * The parent toolbox element receives clicks. The parent toolbox will add an From 12da1fb5770485e4c98c9d6faed781b39d3e4743 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 10 Dec 2025 15:37:01 -0500 Subject: [PATCH 47/51] feat: add more navigation shortcuts (#9523) --- core/shortcut_items.ts | 207 +++++++++++++++-- tests/mocha/shortcut_items_test.js | 359 +++++++++++++++++++++++++++++ 2 files changed, 548 insertions(+), 18 deletions(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 499c6b5fd4d..a1504ce9413 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -15,6 +15,7 @@ import {isCopyable as isICopyable} from './interfaces/i_copyable.js'; import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; import {isDraggable} from './interfaces/i_draggable.js'; import {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {RenderedConnection} from './rendered_connection.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; import {aria} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; @@ -36,6 +37,12 @@ export enum names { REDO = 'redo', READ_FULL_BLOCK_SUMMARY = 'read_full_block_summary', READ_BLOCK_PARENT_SUMMARY = 'read_block_parent_summary', + JUMP_TOP_STACK = 'jump_to_top_of_stack', + JUMP_BOTTOM_STACK = 'jump_to_bottom_of_stack', + JUMP_BLOCK_START = 'jump_to_block_start', + JUMP_BLOCK_END = 'jump_to_block_end', + JUMP_FIRST_BLOCK = 'jump_to_first_block', + JUMP_LAST_BLOCK = 'jump_to_last_block', } /** @@ -389,6 +396,20 @@ export function registerRedo() { ShortcutRegistry.registry.register(redoShortcut); } +/** + * PreconditionFn that returns true if the focused thing is a block or + * belongs to a block (such as field, icon, etc.) + */ +const focusedNodeHasBlockParent = function (workspace: WorkspaceSvg) { + return ( + !workspace.isDragging() && + !getFocusManager().ephemeralFocusTaken() && + !!getFocusManager().getFocusedNode() && + // Either a block or something that has a parent block is focused + !!workspace.getCursor().getSourceBlock() + ); +}; + /** * Registers a keyboard shortcut for re-reading the current selected block's * summary with additional verbosity to help provide context on where the user @@ -400,15 +421,7 @@ export function registerRedo() { export function registerReadFullBlockSummary() { const readFullBlockSummaryShortcut: KeyboardShortcut = { name: names.READ_FULL_BLOCK_SUMMARY, - preconditionFn(workspace) { - return ( - !workspace.isDragging() && - !getFocusManager().ephemeralFocusTaken() && - !!getFocusManager().getFocusedNode() && - // Either a block or something that has a parent block is focused - !!workspace.getCursor().getSourceBlock() - ); - }, + preconditionFn: focusedNodeHasBlockParent, callback(workspace, e) { const selectedBlock = workspace.getCursor().getSourceBlock(); if (!selectedBlock) return false; @@ -433,15 +446,7 @@ export function registerReadBlockParentSummary() { ]); const readBlockParentSummaryShortcut: KeyboardShortcut = { name: names.READ_BLOCK_PARENT_SUMMARY, - preconditionFn(workspace) { - return ( - !workspace.isDragging() && - !getFocusManager().ephemeralFocusTaken() && - !!getFocusManager().getFocusedNode() && - // Either a block or something that has a parent block is focused - !!workspace.getCursor().getSourceBlock() - ); - }, + preconditionFn: focusedNodeHasBlockParent, callback(workspace, e) { const selectedBlock = workspace.getCursor().getSourceBlock(); if (!selectedBlock) return false; @@ -460,6 +465,166 @@ export function registerReadBlockParentSummary() { ShortcutRegistry.registry.register(readBlockParentSummaryShortcut); } +/** + * Registers a keyboard shortcut that sets the focus to the block + * that owns the current focused node. + */ +export function registerJumpBlockStart() { + const jumpBlockStartShortcut: KeyboardShortcut = { + name: names.JUMP_BLOCK_START, + preconditionFn: (workspace) => { + return !workspace.isFlyout && focusedNodeHasBlockParent(workspace); + }, + callback(workspace) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; + getFocusManager().focusNode(selectedBlock); + return true; + }, + keyCodes: [KeyCodes.HOME], + }; + ShortcutRegistry.registry.register(jumpBlockStartShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the + * last input of the block that owns the current focused node. + */ +export function registerJumpBlockEnd() { + const jumpBlockEndShortcut: KeyboardShortcut = { + name: names.JUMP_BLOCK_END, + preconditionFn: (workspace) => { + return !workspace.isFlyout && focusedNodeHasBlockParent(workspace); + }, + callback(workspace) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; + const inputs = selectedBlock.inputList; + if (!inputs.length) return false; + const connection = inputs[inputs.length - 1].connection; + if (!connection || !(connection instanceof RenderedConnection)) + return false; + getFocusManager().focusNode(connection); + return true; + }, + keyCodes: [KeyCodes.END], + }; + ShortcutRegistry.registry.register(jumpBlockEndShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the top block + * in the current stack. + */ +export function registerJumpTopStack() { + const jumpTopStackShortcut: KeyboardShortcut = { + name: names.JUMP_TOP_STACK, + preconditionFn: (workspace) => { + return !workspace.isFlyout && focusedNodeHasBlockParent(workspace); + }, + callback(workspace) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; + const topOfStack = selectedBlock.getRootBlock(); + getFocusManager().focusNode(topOfStack); + return true; + }, + keyCodes: [KeyCodes.PAGE_UP], + }; + ShortcutRegistry.registry.register(jumpTopStackShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the bottom block + * in the current stack. + */ +export function registerJumpBottomStack() { + const jumpBottomStackShortcut: KeyboardShortcut = { + name: names.JUMP_BOTTOM_STACK, + preconditionFn: (workspace) => { + return !workspace.isFlyout && focusedNodeHasBlockParent(workspace); + }, + callback(workspace) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; + // To get the bottom block in a stack, first go to the top of the stack + // Then get the last next connection + // Then get the last descendant of that block + const lastBlock = selectedBlock + .getRootBlock() + .lastConnectionInStack(false) + ?.getSourceBlock(); + if (!lastBlock) return false; + const descendants = lastBlock.getDescendants(true); + const bottomOfStack = descendants[descendants.length - 1]; + getFocusManager().focusNode(bottomOfStack); + return true; + }, + keyCodes: [KeyCodes.PAGE_DOWN], + }; + ShortcutRegistry.registry.register(jumpBottomStackShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the first + * block in the workspace. + */ +export function registerJumpFirstBlock() { + const ctrlHome = ShortcutRegistry.registry.createSerializedKey( + KeyCodes.HOME, + [KeyCodes.CTRL], + ); + const metaHome = ShortcutRegistry.registry.createSerializedKey( + KeyCodes.HOME, + [KeyCodes.META], + ); + const jumpFirstBlockShortcut: KeyboardShortcut = { + name: names.JUMP_FIRST_BLOCK, + preconditionFn: (workspace) => { + return ( + !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() + ); + }, + callback(workspace) { + const topBlocks = workspace.getTopBlocks(true); + if (!topBlocks.length) return false; + getFocusManager().focusNode(topBlocks[0]); + return true; + }, + keyCodes: [ctrlHome, metaHome], + }; + ShortcutRegistry.registry.register(jumpFirstBlockShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the last + * block in the workspace. + */ +export function registerJumpLastBlock() { + const ctrlEnd = ShortcutRegistry.registry.createSerializedKey(KeyCodes.END, [ + KeyCodes.CTRL, + ]); + const metaEnd = ShortcutRegistry.registry.createSerializedKey(KeyCodes.END, [ + KeyCodes.META, + ]); + const jumpLastBlockShortcut: KeyboardShortcut = { + name: names.JUMP_LAST_BLOCK, + preconditionFn: (workspace) => { + return ( + !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() + ); + }, + callback(workspace) { + const allBlocks = workspace.getAllBlocks(true); + if (!allBlocks.length) return false; + getFocusManager().focusNode(allBlocks[allBlocks.length - 1]); + return true; + }, + keyCodes: [ctrlEnd, metaEnd], + }; + ShortcutRegistry.registry.register(jumpLastBlockShortcut); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -476,6 +641,12 @@ export function registerDefaultShortcuts() { registerRedo(); registerReadFullBlockSummary(); registerReadBlockParentSummary(); + registerJumpTopStack(); + registerJumpBottomStack(); + registerJumpBlockStart(); + registerJumpBlockEnd(); + registerJumpFirstBlock(); + registerJumpLastBlock(); } registerDefaultShortcuts(); diff --git a/tests/mocha/shortcut_items_test.js b/tests/mocha/shortcut_items_test.js index dfbae3f0901..7cc99a80d4a 100644 --- a/tests/mocha/shortcut_items_test.js +++ b/tests/mocha/shortcut_items_test.js @@ -560,4 +560,363 @@ suite('Keyboard Shortcut Items', function () { ]), ); }); + + const blockJson = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_1', + 'x': 63, + 'y': 88, + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_1', + 'fields': { + 'NUM': 10, + }, + }, + }, + 'DO': { + 'block': { + 'type': 'controls_forEach', + 'id': 'controls_forEach_1', + 'fields': { + 'VAR': { + 'id': '/wU7DoTDScBz~6hbq-[E', + }, + }, + 'inputs': { + 'LIST': { + 'block': { + 'type': 'lists_repeat', + 'id': 'lists_repeat_1', + 'inputs': { + 'ITEM': { + 'block': { + 'type': 'lists_getIndex', + 'id': 'lists_getIndex_1', + 'fields': { + 'MODE': 'GET', + 'WHERE': 'FROM_START', + }, + 'inputs': { + 'VALUE': { + 'block': { + 'type': 'variables_get', + 'id': 'Lhk_B9iVsV%BhhJ%h]m$', + 'fields': { + 'VAR': { + 'id': '.*~ZjUJ#Sua{h6xyVp7`', + }, + }, + }, + }, + }, + }, + }, + 'NUM': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_2', + 'fields': { + 'NUM': 5, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + 'type': 'controls_forEach', + 'id': 'controls_forEach_2', + 'x': 63, + 'y': 288, + 'fields': { + 'VAR': { + 'id': '+rcR|2HqfZ=vK}N8L{RU', + }, + }, + 'inputs': { + 'DO': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_2', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_3', + 'fields': { + 'NUM': 10, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'text_print_1', + 'inputs': { + 'TEXT': { + 'block': { + 'type': 'text', + 'id': 'text_1', + 'fields': { + 'TEXT': 'last block inside a loop', + }, + }, + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'text_print_2', + 'inputs': { + 'TEXT': { + 'block': { + 'type': 'text', + 'id': 'text_2', + 'fields': { + 'TEXT': 'last block on workspace', + }, + }, + }, + }, + }, + }, + }, + ], + }, + }; + + suite('Jump shortcuts', function () { + setup(function () { + this.getFocusedNodeStub = sinon.stub( + Blockly.getFocusManager(), + 'getFocusedNode', + ); + this.focusNodeSpy = sinon.stub(Blockly.getFocusManager(), 'focusNode'); + Blockly.serialization.workspaces.load(blockJson, this.workspace); + }); + + test('Home focuses current block if block is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME), + ); + sinon.assert.calledWith(this.focusNodeSpy, inListBlock); + }); + + test('Home focuses owning block if field is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME), + ); + sinon.assert.calledWith(this.focusNodeSpy, inListBlock); + }); + + test('End focuses last input on owning block', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END), + ); + const expectedFocus = inListBlock.getInput('AT').connection; + sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + }); + + test('End has no effect if block has no inputs', function () { + const textBlock = this.workspace.getBlockById('text_1'); + this.getFocusedNodeStub.returns(textBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END), + ); + sinon.assert.notCalled(this.focusNodeSpy); + }); + + test('CtrlHome focuses top block in workspace if block is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + const topBlock = this.workspace.getBlockById('controls_repeat_1'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, topBlock); + }); + + test('CtrlHome focuses top block in workspace if field is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + const topBlock = this.workspace.getBlockById('controls_repeat_1'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, topBlock); + }); + + test('CtrlHome focuses top block in workspace if workspace is focused', function () { + this.getFocusedNodeStub.returns(this.workspace); + const topBlock = this.workspace.getBlockById('controls_repeat_1'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, topBlock); + }); + + test('CtrlEnd focuses last block in workspace if block is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + const lastBlock = this.workspace.getBlockById('text_2'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + }); + + test('CtrlEnd focuses last block in workspace if field is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + const lastBlock = this.workspace.getBlockById('text_2'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + }); + + test('CtrlEnd focuses last block in workspace if workspace is focused', function () { + this.getFocusedNodeStub.returns(this.workspace); + const lastBlock = this.workspace.getBlockById('text_2'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + }); + + test('PageUp focuses on first block in stack', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_UP), + ); + const expectedFocus = this.workspace.getBlockById('controls_repeat_1'); + sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + }); + + test('PageDown focuses on last block in stack with nested row blocks', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN), + ); + const expectedFocus = this.workspace.getBlockById('math_number_2'); + sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + }); + + test('PageDown focuses on last block in stack with many stack blocks', function () { + const blockToFocus = this.workspace.getBlockById('text_1'); + this.getFocusedNodeStub.returns(blockToFocus); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN), + ); + const expectedFocus = this.workspace.getBlockById('text_2'); + sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + }); + + suite('in flyout', function () { + test('Home has no effect', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME), + ); + sinon.assert.notCalled(this.focusNodeSpy); + }); + test('End has no effect', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END), + ); + sinon.assert.notCalled(this.focusNodeSpy); + }); + test('CtrlHome focuses top block in flyout workspace', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + const topBlock = this.workspace.getBlockById('controls_repeat_1'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, topBlock); + }); + test('CtrlEnd focuses last block in flyout workspace', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + const lastBlock = this.workspace.getBlockById('text_2'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + }); + test('PageUp has no effect', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_UP), + ); + sinon.assert.notCalled(this.focusNodeSpy); + }); + test('PageDown has no effect', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN), + ); + sinon.assert.notCalled(this.focusNodeSpy); + }); + }); + }); }); From 9a8293096bfc94761f3cc56f1dcf80f998e93358 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Thu, 11 Dec 2025 16:07:51 -0500 Subject: [PATCH 48/51] fix: read parent hierarchy with shift+i (#9527) * fix: read parent hierarchy with shift+i * fix: use a default argument instead of optional --- core/block_svg.ts | 45 ++++++++++++++++++++++++++++-------------- core/shortcut_items.ts | 36 +++++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 107a1dade02..86af67be8c1 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -243,7 +243,11 @@ export class BlockSvg ); } - computeAriaLabel(verbose: boolean = false): string { + computeAriaLabel( + verbose: boolean = false, + minimal: boolean = false, + currentBlock: this | undefined = undefined, + ): string { const labelComponents = []; if (!this.workspace.isFlyout && this.getRootBlock() === this) { @@ -274,23 +278,26 @@ export class BlockSvg const {commaSeparatedSummary, inputCount} = buildBlockSummary( this, verbose, + currentBlock, ); labelComponents.push(commaSeparatedSummary); - if (!this.isEnabled()) { - labelComponents.push('disabled'); - } - if (this.isCollapsed()) { - labelComponents.push('collapsed'); - } - if (this.isShadow()) { - labelComponents.push('replaceable'); - } + if (!minimal) { + if (!this.isEnabled()) { + labelComponents.push('disabled'); + } + if (this.isCollapsed()) { + labelComponents.push('collapsed'); + } + if (this.isShadow()) { + labelComponents.push('replaceable'); + } - if (inputCount > 1) { - labelComponents.push('has inputs'); - } else if (inputCount === 1) { - labelComponents.push('has input'); + if (inputCount > 1) { + labelComponents.push('has inputs'); + } else if (inputCount === 1) { + labelComponents.push('has input'); + } } return labelComponents.join(', '); @@ -2063,7 +2070,11 @@ interface BlockSummary { inputCount: number; } -function buildBlockSummary(block: BlockSvg, verbose: boolean): BlockSummary { +function buildBlockSummary( + block: BlockSvg, + verbose: boolean, + currentBlock?: BlockSvg, +): BlockSummary { let inputCount = 0; // Produce structured segments @@ -2120,6 +2131,10 @@ function buildBlockSummary(block: BlockSvg, verbose: boolean): BlockSummary { true, ); + if (targetBlock === currentBlock) { + nestedSegments.unshift({kind: 'label', text: 'Current block: '}); + } + if (!isNestedInput) { // treat the whole nested summary as a single input segment const nestedText = nestedSegments.map((s) => s.text).join(' '); diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index a1504ce9413..ddf130844b1 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -450,10 +450,38 @@ export function registerReadBlockParentSummary() { callback(workspace, e) { const selectedBlock = workspace.getCursor().getSourceBlock(); if (!selectedBlock) return false; - const parentBlock = selectedBlock.getParent(); - if (parentBlock) { - const blockSummary = parentBlock.computeAriaLabel(true); - aria.announceDynamicAriaState(`Parent block: ${blockSummary}`); + + const toAnnounce = []; + // First go up the chain of output connections and start finding parents from there + // because the outputs of a block are read anyway, so we don't need to repeat them + + let startBlock = selectedBlock; + while (startBlock.outputConnection?.isConnected()) { + startBlock = startBlock.getParent()!; + } + + if (startBlock !== selectedBlock) { + toAnnounce.push( + startBlock.computeAriaLabel(false, true, selectedBlock), + ); + } + + let parent = startBlock.getParent(); + while (parent) { + toAnnounce.push(parent.computeAriaLabel(false, true)); + parent = parent.getParent(); + } + + if (toAnnounce.length) { + toAnnounce.reverse(); + if (!selectedBlock.outputConnection?.isConnected()) { + // The current block was already read out earlier if it has an output connection + toAnnounce.push( + `Current block: ${selectedBlock.computeAriaLabel(false, true)}`, + ); + } + + aria.announceDynamicAriaState(`Parent blocks: ${toAnnounce.join(',')}`); } else { aria.announceDynamicAriaState('Current block has no parent'); } From 1216655cd54ec7c31f1616e066dcc304f0dde671 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Fri, 12 Dec 2025 13:13:12 -0800 Subject: [PATCH 49/51] feat: Better unconstrained announcements (experimental) (#9529) * feat: Improve annonucements for unconstrained. * chore: Lint fixes. --- core/block_svg.ts | 109 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 7 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 86af67be8c1..f645cb6c0d6 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -1901,33 +1901,37 @@ export class BlockSvg /** Starts a drag on the block. */ startDrag(e?: PointerEvent): void { + const location = this.getRelativeToSurfaceXY(); this.dragStrategy.startDrag(e); const dragStrategy = this.dragStrategy as BlockDragStrategy; const candidate = dragStrategy.connectionCandidate?.neighbour ?? null; this.currentConnectionCandidate = candidate; - this.announceDynamicAriaState(true, false); + this.announceDynamicAriaState(true, false, location); } /** Drags the block to the given location. */ drag(newLoc: Coordinate, e?: PointerEvent): void { + const prevLocation = this.getRelativeToSurfaceXY(); this.dragStrategy.drag(newLoc, e); const dragStrategy = this.dragStrategy as BlockDragStrategy; const candidate = dragStrategy.connectionCandidate?.neighbour ?? null; this.currentConnectionCandidate = candidate; - this.announceDynamicAriaState(true, false, newLoc); + this.announceDynamicAriaState(true, false, prevLocation, newLoc); } /** Ends the drag on the block. */ endDrag(e?: PointerEvent): void { + const location = this.getRelativeToSurfaceXY(); this.dragStrategy.endDrag(e); this.currentConnectionCandidate = null; - this.announceDynamicAriaState(false, false); + this.announceDynamicAriaState(false, false, location); } /** Moves the block back to where it was at the start of a drag. */ revertDrag(): void { + const location = this.getRelativeToSurfaceXY(); this.dragStrategy.revertDrag(); - this.announceDynamicAriaState(false, true); + this.announceDynamicAriaState(false, true, location); } /** @@ -2024,11 +2028,14 @@ export class BlockSvg * * @param isMoving Whether the specified block is currently being moved. * @param isCanceled Whether the previous movement operation has been canceled. + * @param prevLoc Either the current location of the block, or its previous + * location if it's been moved (and a newLoc is provided). * @param newLoc The new location the block is moving to (if unconstrained). */ private announceDynamicAriaState( isMoving: boolean, isCanceled: boolean, + prevLoc: Coordinate, newLoc?: Coordinate, ) { if (isCanceled) { @@ -2057,11 +2064,99 @@ export class BlockSvg aria.announceDynamicAriaState(announcementContext.join(' ')); } else if (newLoc) { // The block is being freely dragged. - aria.announceDynamicAriaState( - `Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`, - ); + const direction = this.diff(prevLoc, newLoc); + if (direction === CoordinateShift.MOVE_NORTH) { + aria.announceDynamicAriaState('Moved block up.'); + } else if (direction === CoordinateShift.MOVE_EAST) { + aria.announceDynamicAriaState('Moved block right.'); + } else if (direction === CoordinateShift.MOVE_SOUTH) { + aria.announceDynamicAriaState('Moved block down.'); + } else if (direction === CoordinateShift.MOVE_WEST) { + aria.announceDynamicAriaState('Moved block left.'); + } else if (direction === CoordinateShift.MOVE_NORTHEAST) { + aria.announceDynamicAriaState('Moved block up and right.'); + } else if (direction === CoordinateShift.MOVE_SOUTHEAST) { + aria.announceDynamicAriaState('Moved block down and right.'); + } else if (direction === CoordinateShift.MOVE_SOUTHWEST) { + aria.announceDynamicAriaState('Moved block down and left.'); + } else if (direction === CoordinateShift.MOVE_NORTHWEST) { + aria.announceDynamicAriaState('Moved block up and left.'); + } + // Else don't announce anything because the block didn't move. } } + + private diff(fromCoord: Coordinate, toCoord: Coordinate): CoordinateShift { + const xDiff = this.diffAxis(fromCoord.x, toCoord.x); + const yDiff = this.diffAxis(fromCoord.y, toCoord.y); + if (xDiff === AxisShift.SAME && yDiff == AxisShift.SAME) { + return CoordinateShift.STAY_STILL; + } + if (xDiff === AxisShift.SAME) { + // Move vertically. + if (yDiff === AxisShift.SMALLER) { + return CoordinateShift.MOVE_NORTH; + } else { + return CoordinateShift.MOVE_SOUTH; + } + } else if (yDiff === AxisShift.SAME) { + // Move horizontally. + if (xDiff === AxisShift.SMALLER) { + return CoordinateShift.MOVE_WEST; + } else { + return CoordinateShift.MOVE_EAST; + } + } else { + // Move diagonally. + if (xDiff === AxisShift.SMALLER) { + // Move left. + if (yDiff === AxisShift.SMALLER) { + return CoordinateShift.MOVE_NORTHWEST; + } else { + return CoordinateShift.MOVE_SOUTHWEST; + } + } else { + // Move right. + if (yDiff === AxisShift.SMALLER) { + return CoordinateShift.MOVE_NORTHEAST; + } else { + return CoordinateShift.MOVE_SOUTHEAST; + } + } + } + } + + private diffAxis(fromX: number, toX: number): AxisShift { + if (this.isEqual(fromX, toX)) { + return AxisShift.SAME; + } else if (toX > fromX) { + return AxisShift.LARGER; + } else { + return AxisShift.SMALLER; + } + } + + private isEqual(x: number, y: number): boolean { + return Math.abs(x - y) < 1e-10; + } +} + +enum CoordinateShift { + MOVE_NORTH, + MOVE_EAST, + MOVE_SOUTH, + MOVE_WEST, + MOVE_NORTHEAST, + MOVE_SOUTHEAST, + MOVE_SOUTHWEST, + MOVE_NORTHWEST, + STAY_STILL, +} + +enum AxisShift { + SMALLER, + SAME, + LARGER, } interface BlockSummary { From bb342f9644683c86aea3fee43df2f0f32a3e0445 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 15 Dec 2025 10:13:23 -0800 Subject: [PATCH 50/51] 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 f645cb6c0d6..d8ac52e29e0 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 aecead2e80c..a0a3804535a 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 de0955b9331..be338108210 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 b7aaf5e06bf..bfa19816eab 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 7132d9ab16a..175e80ff55d 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 74de275a459..63e3f6a7c8a 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 26be0ed12e2..1d6c4f31776 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 64d1bf143c6..8535f89830b 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 1f16f24c6e0..3e0c8324e7b 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( From 7288860bd4e8aae005ba397b90ac261c96dc0e08 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 18 Dec 2025 09:53:14 -0800 Subject: [PATCH 51/51] fix: Clean up accessibility node hierarchy (experimental) (#9449) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9304 ### Proposed Changes Fixes non-visual parent roles and rendered connection navigation policy. ### Reason for Changes Other PRs have made progress on removing extraneous accessibility nodes with #9446 being essentially the last of these. Ensuring that parent/child relationships are correct is the last step in ensuring that the entirety of the accessibility node graph is correctly representing the DOM and navigational structure of Blockly. This can have implications and (ideally) improvements for certain screen reader modes that provide higher-level summarization and sometimes navigation (bypassing Blockly's keyboard navigation) since it avoids an incorrect flat node structure and instead ensures correct hierarchy and ordering. It was discovered during the development of the PR that setting `aria-owns` properties to ensure that all focusable accessibility nodes have the correct parent/child relationships (particularly for blocks) isn't actually viable per the analysis summarized in this comment: https://github.com/RaspberryPiFoundation/blockly/pull/9449#issuecomment-3663234767. At a high level introducing these relationships seems to actually cause problems in both ChromeVox and Voiceover. Part of the analysis discovered that nodes set with the `presentation` role aren't going to behave correctly due to the spec ignoring that role if any children of such elements are focusable, so this PR does change those over to `generic` which is more correct. They are still missing in Chrome's accessibility node viewer, and `generic` _seems_ to introduce slightly better `group` behaviors on VoiceOver (that is, it seems to reduce some of the `group` announcements which VoiceOver is known for over-specifying). Note that some tests needed to be updated to ensure that they were properly rendering blocks (in order for `RenderedConnection.canBeFocused()` to behave correctly) in the original implementation of the PR. Only one test actually changed in behavior because it seemed like it was incorrect before--the particular connection being tested wasn't actually navigable and the change to `canBeFocused` actually enforces that. These changes were kept even though the behaviors weren't needed anymore since it's still a bit more correct than before. Overall, #9304 is closed here because the tree seems to be about as good as it can get with current knowledge (assuming no other invalid roles need to be fixed, but that can be addressed in separate issues as needed). ### Test Coverage No automated tests are needed for this since it's experimental but it has been manually tested with both ChromeVox and Voiceover. ### Documentation No documentation changes are needed for these experimental changes. ### Additional Information Note that there are some limitations with this approach: text editors and listboxes (e.g. for comboboxes) are generally outside of the hierarchy represented by the Blockly workspace. This is an existing issue that remains unaffected by these changes, and fixing it to be both ARIA compliant and consistent with the DOM may not be possible (though it doesn't seem like there's a strong requirement to maintain DOM and accessibility node tree hierarchical relationships). The analysis linked above also considered introducing a top-level `application` role which might change some of the automated behaviors of certain roles but this only seemed to worsen local testing with ChromeVox so it was excluded. --- core/block_svg.ts | 1 + core/rendered_connection.ts | 2 +- core/utils/dom.ts | 2 +- tests/mocha/cursor_test.js | 8 +- tests/mocha/navigation_test.js | 123 +++++++++++------- tests/mocha/test_helpers/block_definitions.js | 4 +- 6 files changed, 84 insertions(+), 56 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index d8ac52e29e0..ec39e681445 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -365,6 +365,7 @@ export class BlockSvg this.workspace.getCanvas().appendChild(svg); } this.initialized = true; + this.recomputeAriaLabel(); } /** diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index f824cdb38f9..ffef106f579 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -688,7 +688,7 @@ export class RenderedConnection /** See IFocusableNode.canBeFocused. */ canBeFocused(): boolean { - return true; + return this.findHighlightSvg() !== null; } private findHighlightSvg(): SVGPathElement | null { diff --git a/core/utils/dom.ts b/core/utils/dom.ts index e32cad4d604..a7fc35dc3a5 100644 --- a/core/utils/dom.ts +++ b/core/utils/dom.ts @@ -64,7 +64,7 @@ export function createSvgElement( opt_parent.appendChild(e); } if (name === Svg.SVG || name === Svg.G) { - aria.setRole(e, aria.Role.PRESENTATION); + aria.setRole(e, aria.Role.GENERIC); } return e; } diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index 2273ec4b381..6816cf8c1e7 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -342,7 +342,7 @@ suite('Cursor', function () { }); suite('one empty block', function () { setup(function () { - this.blockA = this.workspace.newBlock('empty_block'); + this.blockA = createRenderedBlock(this.workspace, 'empty_block'); }); teardown(function () { this.workspace.clear(); @@ -359,7 +359,7 @@ suite('Cursor', function () { suite('one stack block', function () { setup(function () { - this.blockA = this.workspace.newBlock('stack_block'); + this.blockA = createRenderedBlock(this.workspace, 'stack_block'); }); teardown(function () { this.workspace.clear(); @@ -376,7 +376,7 @@ suite('Cursor', function () { suite('one row block', function () { setup(function () { - this.blockA = this.workspace.newBlock('row_block'); + this.blockA = createRenderedBlock(this.workspace, 'row_block'); }); teardown(function () { this.workspace.clear(); @@ -392,7 +392,7 @@ suite('Cursor', function () { }); suite('one c-hat block', function () { setup(function () { - this.blockA = this.workspace.newBlock('c_hat_block'); + this.blockA = createRenderedBlock(this.workspace, 'c_hat_block'); }); teardown(function () { this.workspace.clear(); diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js index 3a9292b9209..37972318d50 100644 --- a/tests/mocha/navigation_test.js +++ b/tests/mocha/navigation_test.js @@ -5,10 +5,10 @@ */ import {assert} from '../../node_modules/chai/index.js'; +import {createRenderedBlock} from './test_helpers/block_definitions.js'; import { sharedTestSetup, sharedTestTeardown, - workspaceTeardown, } from './test_helpers/setup_teardown.js'; suite('Navigation', function () { @@ -89,13 +89,28 @@ suite('Navigation', function () { ]); this.workspace = Blockly.inject('blocklyDiv', {}); this.navigator = this.workspace.getNavigator(); - const statementInput1 = this.workspace.newBlock('input_statement'); - const statementInput2 = this.workspace.newBlock('input_statement'); - const statementInput3 = this.workspace.newBlock('input_statement'); - const statementInput4 = this.workspace.newBlock('input_statement'); - const fieldWithOutput = this.workspace.newBlock('field_input'); - const doubleValueInput = this.workspace.newBlock('double_value_input'); - const valueInput = this.workspace.newBlock('value_input'); + const statementInput1 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const statementInput2 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const statementInput3 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const statementInput4 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const fieldWithOutput = createRenderedBlock(this.workspace, 'field_input'); + const doubleValueInput = createRenderedBlock( + this.workspace, + 'double_value_input', + ); + const valueInput = createRenderedBlock(this.workspace, 'value_input'); statementInput1.nextConnection.connect(statementInput2.previousConnection); statementInput1.inputList[0].connection.connect( @@ -355,13 +370,25 @@ suite('Navigation', function () { 'helpUrl': '', }, ]); - const noNextConnection = this.workspace.newBlock('top_connection'); - const fieldAndInputs = this.workspace.newBlock('fields_and_input'); - const twoFields = this.workspace.newBlock('two_fields'); - const fieldAndInputs2 = this.workspace.newBlock('fields_and_input2'); - const noPrevConnection = this.workspace.newBlock('start_block'); - const hiddenField = this.workspace.newBlock('hidden_field'); - const hiddenInput = this.workspace.newBlock('hidden_input'); + const noNextConnection = createRenderedBlock( + this.workspace, + 'top_connection', + ); + const fieldAndInputs = createRenderedBlock( + this.workspace, + 'fields_and_input', + ); + const twoFields = createRenderedBlock(this.workspace, 'two_fields'); + const fieldAndInputs2 = createRenderedBlock( + this.workspace, + 'fields_and_input2', + ); + const noPrevConnection = createRenderedBlock( + this.workspace, + 'start_block', + ); + const hiddenField = createRenderedBlock(this.workspace, 'hidden_field'); + const hiddenInput = createRenderedBlock(this.workspace, 'hidden_input'); this.blocks.noNextConnection = noNextConnection; this.blocks.fieldAndInputs = fieldAndInputs; this.blocks.twoFields = twoFields; @@ -373,28 +400,47 @@ suite('Navigation', function () { hiddenField.inputList[0].fieldRow[1].setVisible(false); hiddenInput.inputList[1].setVisible(false); - const dummyInput = this.workspace.newBlock('dummy_input'); - const dummyInputValue = this.workspace.newBlock('dummy_inputValue'); - const fieldWithOutput2 = this.workspace.newBlock('field_input'); + const dummyInput = createRenderedBlock(this.workspace, 'dummy_input'); + const dummyInputValue = createRenderedBlock( + this.workspace, + 'dummy_inputValue', + ); + const fieldWithOutput2 = createRenderedBlock( + this.workspace, + 'field_input', + ); this.blocks.dummyInput = dummyInput; this.blocks.dummyInputValue = dummyInputValue; this.blocks.fieldWithOutput2 = fieldWithOutput2; - const secondBlock = this.workspace.newBlock('input_statement'); - const outputNextBlock = this.workspace.newBlock('output_next'); + const secondBlock = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const outputNextBlock = createRenderedBlock( + this.workspace, + 'output_next', + ); this.blocks.secondBlock = secondBlock; this.blocks.outputNextBlock = outputNextBlock; - const buttonBlock = this.workspace.newBlock('buttons', 'button_block'); - const buttonInput1 = this.workspace.newBlock( + const buttonBlock = createRenderedBlock( + this.workspace, + 'buttons', + 'button_block', + ); + const buttonInput1 = createRenderedBlock( + this.workspace, 'field_input', 'button_input1', ); - const buttonInput2 = this.workspace.newBlock( + const buttonInput2 = createRenderedBlock( + this.workspace, 'field_input', 'button_input2', ); - const buttonNext = this.workspace.newBlock( + const buttonNext = createRenderedBlock( + this.workspace, 'input_statement', 'button_next', ); @@ -420,15 +466,6 @@ suite('Navigation', function () { this.workspace.cleanUp(); }); suite('Next', function () { - setup(function () { - this.singleBlockWorkspace = new Blockly.Workspace(); - const singleBlock = this.singleBlockWorkspace.newBlock('two_fields'); - this.blocks.singleBlock = singleBlock; - }); - teardown(function () { - workspaceTeardown.call(this, this.singleBlockWorkspace); - }); - test('fromPreviousToBlock', function () { const prevConnection = this.blocks.statementInput1.previousConnection; const nextNode = this.navigator.getNextSibling(prevConnection); @@ -471,8 +508,6 @@ suite('Navigation', function () { }); test('fromFieldToNestedBlock', function () { const field = this.blocks.statementInput1.inputList[0].fieldRow[1]; - const inputConnection = - this.blocks.statementInput1.inputList[0].connection; const nextNode = this.navigator.getNextSibling(field); assert.equal(nextNode, this.blocks.fieldWithOutput); }); @@ -576,7 +611,6 @@ suite('Navigation', function () { const prevNode = this.navigator.getPreviousSibling( this.blocks.fieldWithOutput, ); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; assert.equal(prevNode, [...this.blocks.statementInput1.getFields()][1]); }); test('fromNextToBlock', function () { @@ -617,14 +651,12 @@ suite('Navigation', function () { assert.isNull(prevNode); }); test('fromFieldToInput', function () { - const outputBlock = this.workspace.newBlock('field_input'); + const outputBlock = createRenderedBlock(this.workspace, 'field_input'); this.blocks.fieldAndInputs2.inputList[0].connection.connect( outputBlock.outputConnection, ); const field = this.blocks.fieldAndInputs2.inputList[1].fieldRow[0]; - const inputConnection = - this.blocks.fieldAndInputs2.inputList[0].connection; const prevNode = this.navigator.getPreviousSibling(field); assert.equal(prevNode, outputBlock); }); @@ -701,18 +733,13 @@ suite('Navigation', function () { }); suite('In', function () { - setup(function () { - this.emptyWorkspace = Blockly.inject(document.createElement('div'), {}); - }); - teardown(function () { - workspaceTeardown.call(this, this.emptyWorkspace); - }); - test('fromInputToOutput', function () { + // The first child is the connected block since the connection itself + // cannot be navigated to directly. const input = this.blocks.statementInput1.inputList[0]; const inNode = this.navigator.getFirstChild(input.connection); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; - assert.equal(inNode, outputConnection); + const connectedBlock = this.blocks.fieldWithOutput; + assert.equal(inNode, connectedBlock); }); test('fromInputToNull', function () { const input = this.blocks.statementInput2.inputList[0]; diff --git a/tests/mocha/test_helpers/block_definitions.js b/tests/mocha/test_helpers/block_definitions.js index 26507b29cb8..e5ca106d2c4 100644 --- a/tests/mocha/test_helpers/block_definitions.js +++ b/tests/mocha/test_helpers/block_definitions.js @@ -196,8 +196,8 @@ export function createTestBlock() { }; } -export function createRenderedBlock(workspaceSvg, type) { - const block = workspaceSvg.newBlock(type); +export function createRenderedBlock(workspaceSvg, type, opt_id) { + const block = workspaceSvg.newBlock(type, opt_id); block.initSvg(); block.render(); return block;