From b1d3d6d9180fcfa9db80b7ba0bac9d4a43a9eade Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 30 Oct 2025 00:37:02 +0000 Subject: [PATCH 1/2] feat: Clean up a11y node hierarchy. --- core/block_svg.ts | 53 +++++++++++++++++++++++++++++++++++++ core/rendered_connection.ts | 4 +++ 2 files changed, 57 insertions(+) diff --git a/core/block_svg.ts b/core/block_svg.ts index 00b3b816d44..37ec38e9378 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -232,6 +232,46 @@ export class BlockSvg * @internal */ recomputeAriaLabel() { + if (this.initialized) { + const childElemIds: string[] = []; + for (const input of this.inputList) { + if (input.isVisible() && input.connection) { + if (input.connection.type === ConnectionType.NEXT_STATEMENT) { + let currentBlock: BlockSvg | null = + input.connection.targetBlock() as BlockSvg | null; + while (currentBlock) { + if (currentBlock.canBeFocused()) { + childElemIds.push(currentBlock.getBlockSvgFocusElem().id); + } + currentBlock = currentBlock.getNextBlock(); + } + } else if (input.connection.type === ConnectionType.INPUT_VALUE) { + const inpBlock = input.connection.targetBlock() as BlockSvg | null; + if (inpBlock && inpBlock.canBeFocused()) { + childElemIds.push(inpBlock.getBlockSvgFocusElem().id); + } + } + } + for (const field of input.fieldRow) { + if (field.getSvgRoot() && field.canBeFocused()) { + // Only track the field if it's been initialized. + childElemIds.push(field.getFocusableElement().id); + } + } + for (const icon of this.icons) { + if (icon.canBeFocused()) { + childElemIds.push(icon.getFocusableElement().id); + } + } + for (const connection of this.getConnections_(true)) { + if (connection.canBeFocused() && connection.isHighlighted()) { + childElemIds.push(connection.getFocusableElement().id); + } + } + } + aria.setState(this.getBlockSvgFocusElem(), aria.State.OWNS, childElemIds); + } + if (this.isSimpleReporter()) { const field = Array.from(this.getFields())[0]; if (field.isFullBlockField() && field.isCurrentlyEditable()) return; @@ -244,6 +284,12 @@ export class BlockSvg ); } + private getBlockSvgFocusElem(): Element { + // Note that this deviates from getFocusableElement() to ensure that + // single field blocks are properly set up in the hierarchy. + return this.pathObject.svgPath; + } + private computeAriaLabel(): string { const {blockSummary, inputCount} = buildBlockSummary(this); const inputSummary = inputCount @@ -352,6 +398,7 @@ export class BlockSvg this.workspace.getCanvas().appendChild(svg); } this.initialized = true; + this.recomputeAriaLabel(); } /** @@ -456,6 +503,12 @@ export class BlockSvg this.applyColour(); this.workspace.recomputeAriaTree(); + this.recomputeAriaLabelRecursive(); + } + + private recomputeAriaLabelRecursive() { + this.recomputeAriaLabel(); + this.parentBlock_?.recomputeAriaLabelRecursive(); } /** diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index bbf32006bc8..97b56c57896 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -362,6 +362,8 @@ export class RenderedConnection aria.setState(highlightSvg, aria.State.LABEL, 'Open connection'); } } + + this.sourceBlock_.recomputeAriaLabel(); } /** Remove the highlighting around this connection. */ @@ -373,6 +375,8 @@ export class RenderedConnection if (highlightSvg) { highlightSvg.style.display = 'none'; } + + this.sourceBlock_.recomputeAriaLabel(); } /** Returns true if this connection is highlighted, false otherwise. */ From f81e2b2fecae5aa28c58a8c94ad8ddeb570d4071 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 12 Nov 2025 01:02:51 +0000 Subject: [PATCH 2/2] fix: Fix CI failures. Addresses some observed gaps in behaviors with connections. This is unfortunately mainly a patch without fully understanding the deep issue being avoided but it seems reasonable for the experimental branch. --- core/block_svg.ts | 13 +++++++++++-- core/rendered_connection.ts | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 37ec38e9378..fd3e7b9a6bd 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -264,7 +264,14 @@ export class BlockSvg } } for (const connection of this.getConnections_(true)) { - if (connection.canBeFocused() && connection.isHighlighted()) { + // TODO: Somehow it's possible for a connection to be highlighted but + // have no focusable element. This might be some sort of race + // condition or perhaps dispose-esque situation happening. + if ( + connection.canBeFocused() && + connection.isHighlighted() && + connection.findHighlightSvg() !== null + ) { childElemIds.push(connection.getFocusableElement().id); } } @@ -274,7 +281,9 @@ export class BlockSvg if (this.isSimpleReporter()) { const field = Array.from(this.getFields())[0]; - if (field.isFullBlockField() && field.isCurrentlyEditable()) return; + if (field && field.isFullBlockField() && field.isCurrentlyEditable()) { + return; + } } aria.setState( diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index 97b56c57896..5f305fdc271 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -692,7 +692,8 @@ export class RenderedConnection return true; } - private findHighlightSvg(): SVGPathElement | null { + // TODO: Figure out how to make this private again. + findHighlightSvg(): SVGPathElement | null { // This cast is valid as TypeScript's definition is wrong. See: // https://github.com/microsoft/TypeScript/issues/60996. return document.getElementById(this.id) as