From 5c804956dc4c889236fa7d8992918533c568d7c0 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 11 May 2026 10:20:36 -0700 Subject: [PATCH 1/6] fix: Improve display of workspace focus rings --- packages/blockly/core/inject.ts | 9 +++++ packages/blockly/core/workspace_svg.ts | 46 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/packages/blockly/core/inject.ts b/packages/blockly/core/inject.ts index dffdeef47d8..e86d2f27a1e 100644 --- a/packages/blockly/core/inject.ts +++ b/packages/blockly/core/inject.ts @@ -13,6 +13,7 @@ import * as common from './common.js'; import * as Css from './css.js'; import * as dropDownDiv from './dropdowndiv.js'; import {Grid} from './grid.js'; +import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import {Options} from './options.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import * as Tooltip from './tooltip.js'; @@ -73,6 +74,14 @@ export function inject( common.setMainWorkspace(workspace); }); + containerElement?.getRootNode().addEventListener('keydown', (( + e: KeyboardEvent, + ) => { + if (e.key === 'Tab') { + keyboardNavigationController.setIsActive(true); + } + }) as EventListener); + browserEvents.conditionalBind( subContainer, 'keydown', diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index c886219d40c..28330fb90ff 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -333,6 +333,16 @@ export class WorkspaceSvg svgBubbleCanvas_!: SVGElement; zoomControls_: ZoomControls | null = null; + /** + * Focus ring in the workspace. + */ + private workspaceFocusRing: Element | null = null; + + /** + * Selection ring inside the workspace. + */ + private workspaceSelectionRing: Element | null = null; + /** * Navigator that handles moving focus between items in this workspace in * response to keyboard navigation commands. @@ -848,6 +858,17 @@ export class WorkspaceSvg // Only the top-level and flyout workspaces should be tabbable. getFocusManager().registerTree(this, !!this.injectionDiv || this.isFlyout); + this.workspaceSelectionRing = dom.createSvgElement('rect', { + fill: 'none', + class: 'blocklyWorkspaceSelectionRing', + }); + this.getSvgGroup().appendChild(this.workspaceSelectionRing); + this.workspaceFocusRing = dom.createSvgElement('rect', { + fill: 'none', + class: 'blocklyWorkspaceFocusRing', + }); + this.getSvgGroup().appendChild(this.workspaceFocusRing); + return this.svgGroup_; } @@ -929,6 +950,9 @@ export class WorkspaceSvg this.dummyWheelListener = null; } + this.workspaceFocusRing?.remove(); + this.workspaceSelectionRing?.remove(); + if (getFocusManager().isRegistered(this)) { getFocusManager().unregisterTree(this); } @@ -1108,6 +1132,28 @@ export class WorkspaceSvg this.scrollbar.resize(); } this.updateScreenCalculations(); + + if (!this.workspaceFocusRing || !this.workspaceSelectionRing) return; + this.resizeWorkspaceRing(this.workspaceSelectionRing, 5); + this.resizeWorkspaceRing(this.workspaceFocusRing, 0); + } + + /** + * Sizes the given focus/selection ring inside the bounds of the workspace. + * + * @param ring The interior workspace ring indicator to resize. + * @param inset How many pixels in from the bounds of the workspace the ring + * should be positioned. + */ + private resizeWorkspaceRing(ring: Element, inset: number) { + const metrics = this.getMetrics(); + ring.setAttribute('x', `${metrics.absoluteLeft + inset}`); + ring.setAttribute('y', `${metrics.absoluteTop + inset}`); + ring.setAttribute('width', `${Math.max(0, metrics.viewWidth - inset * 2)}`); + ring.setAttribute( + 'height', + `${Math.max(0, metrics.svgHeight - inset * 2)}`, + ); } /** From 80f3e89985b50973f220eaac11a800c06cd99373 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 11 May 2026 10:42:34 -0700 Subject: [PATCH 2/6] fix: Only bind tab listener once --- packages/blockly/core/inject.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/blockly/core/inject.ts b/packages/blockly/core/inject.ts index e86d2f27a1e..8afe54181d8 100644 --- a/packages/blockly/core/inject.ts +++ b/packages/blockly/core/inject.ts @@ -74,14 +74,6 @@ export function inject( common.setMainWorkspace(workspace); }); - containerElement?.getRootNode().addEventListener('keydown', (( - e: KeyboardEvent, - ) => { - if (e.key === 'Tab') { - keyboardNavigationController.setIsActive(true); - } - }) as EventListener); - browserEvents.conditionalBind( subContainer, 'keydown', @@ -327,6 +319,11 @@ function bindDocumentEvents() { // should run regardless of what other touch event handlers have run. browserEvents.bind(document, 'touchend', null, Touch.longStop); browserEvents.bind(document, 'touchcancel', null, Touch.longStop); + browserEvents.bind(document, 'keydown', null, function (e: KeyboardEvent) { + if (e.key === 'Tab') { + keyboardNavigationController.setIsActive(true); + } + }); } documentEventsBound = true; } From d931d5746eec6009df6c33d18523e5ec4374c782 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 11 May 2026 12:16:50 -0700 Subject: [PATCH 3/6] refactor: Make setup of workspace focus rings more consistent with other DOM elements --- packages/blockly/core/workspace_svg.ts | 28 ++++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index 28330fb90ff..b197baa0d2f 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -801,6 +801,23 @@ export class WorkspaceSvg } } + this.workspaceSelectionRing = dom.createSvgElement( + Svg.RECT, + { + fill: 'none', + class: 'blocklyWorkspaceSelectionRing', + }, + this.svgGroup_, + ); + this.workspaceFocusRing = dom.createSvgElement( + Svg.RECT, + { + fill: 'none', + class: 'blocklyWorkspaceFocusRing', + }, + this.svgGroup_, + ); + this.layerManager = new LayerManager(this); // Assign the canvases for backwards compatibility. this.svgBlockCanvas_ = this.layerManager.getBlockLayer(); @@ -858,17 +875,6 @@ export class WorkspaceSvg // Only the top-level and flyout workspaces should be tabbable. getFocusManager().registerTree(this, !!this.injectionDiv || this.isFlyout); - this.workspaceSelectionRing = dom.createSvgElement('rect', { - fill: 'none', - class: 'blocklyWorkspaceSelectionRing', - }); - this.getSvgGroup().appendChild(this.workspaceSelectionRing); - this.workspaceFocusRing = dom.createSvgElement('rect', { - fill: 'none', - class: 'blocklyWorkspaceFocusRing', - }); - this.getSvgGroup().appendChild(this.workspaceFocusRing); - return this.svgGroup_; } From f5c5becdb34fb4b37c9f1eb64cadbd84794262ab Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 11 May 2026 12:17:51 -0700 Subject: [PATCH 4/6] fix: Fix major performance regression in test suite (and perhaps IRL) --- packages/blockly/core/css.ts | 10 +++++----- packages/blockly/core/dropdowndiv.ts | 10 ++++++++++ packages/blockly/core/widgetdiv.ts | 12 ++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index b4ecf9e4e17..0c186545130 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -632,11 +632,11 @@ input[type=number] { .blocklyWorkspace.blocklyActiveFocus .blocklyWorkspaceFocusRing, /* Focus in widget/dropdown div considered to be in workspace. */ -.blocklyKeyboardNavigation:has( - .blocklyWidgetDiv:focus-within, - .blocklyDropDownDiv:focus-within -) - .blocklyWorkspace + .blocklyKeyboardNavigation + .blocklyWorkspace.blocklyShowingDropDownDiv + .blocklyWorkspaceFocusRing, +.blocklyKeyboardNavigation + .blocklyWorkspace.blocklyShowingWidgetDiv .blocklyWorkspaceFocusRing { stroke: var(--blockly-active-tree-color); stroke-width: calc(var(--blockly-selection-width) * 2); diff --git a/packages/blockly/core/dropdowndiv.ts b/packages/blockly/core/dropdowndiv.ts index f35ee2fc8bf..310b8c19380 100644 --- a/packages/blockly/core/dropdowndiv.ts +++ b/packages/blockly/core/dropdowndiv.ts @@ -50,6 +50,12 @@ export const PADDING_Y = 16; /** Length of animations in seconds. */ export const ANIMATION_TIME = 0.25; +/** + * Class applied to the element that is displaying the DropDownDiv, used to + * apply focus styles. + */ +const SHOWING_DROPDOWNDIV_SELECTOR = 'blocklyShowingDropDownDiv'; + /** * Timer for animation out, to be cleared if we need to immediately hide * without disrupting new shows. @@ -343,6 +349,7 @@ function showPositionedByRect( workspace = workspace.options.parentWorkspace; } setBoundsElement(workspace.getParentSvg().parentNode as Element | null); + workspace.getFocusableElement().classList.add(SHOWING_DROPDOWNDIV_SELECTOR); return show( field, sourceBlock.RTL, @@ -735,6 +742,9 @@ export function hideWithoutAnimation() { aria.State.OWNS, existingOwnership.replace(div.id, ''), ); + workspace + .getFocusableElement() + .classList.remove(SHOWING_DROPDOWNDIV_SELECTOR); workspace.markFocused(); diff --git a/packages/blockly/core/widgetdiv.ts b/packages/blockly/core/widgetdiv.ts index 6d8ad58d655..05b3db8bed0 100644 --- a/packages/blockly/core/widgetdiv.ts +++ b/packages/blockly/core/widgetdiv.ts @@ -41,6 +41,12 @@ let containerDiv: HTMLDivElement | null; /** Callback to FocusManager to return ephemeral focus when the div closes. */ let returnEphemeralFocus: ReturnEphemeralFocus | null = null; +/** + * Class applied to the element that is displaying the WidgetDiv, used to apply + * focus styles. + */ +const SHOWING_WIDGETDIV_SELECTOR = 'blocklyShowingWidgetDiv'; + /** * Returns the HTML container for editor widgets. * @@ -139,6 +145,9 @@ export function show( aria.State.OWNS, existingOwnership ? [existingOwnership, div.id] : div.id, ); + ownerWorkspace + .getFocusableElement() + .classList.add(SHOWING_WIDGETDIV_SELECTOR); const parentDiv = common.getParentContainer(); parentDiv?.appendChild(div); @@ -209,6 +218,9 @@ export function hide() { aria.State.OWNS, existingOwnership.replace(containerDiv.id, ''), ); + ownerWorkspace + .getFocusableElement() + .classList.remove(SHOWING_WIDGETDIV_SELECTOR); } /** From aaeaf3c57214219eb54a865b816485cc19692bd3 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 11 May 2026 12:24:49 -0700 Subject: [PATCH 5/6] fix: Fix adding of class in `DropDownDiv` --- packages/blockly/core/dropdowndiv.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/dropdowndiv.ts b/packages/blockly/core/dropdowndiv.ts index 310b8c19380..5d919426441 100644 --- a/packages/blockly/core/dropdowndiv.ts +++ b/packages/blockly/core/dropdowndiv.ts @@ -349,7 +349,6 @@ function showPositionedByRect( workspace = workspace.options.parentWorkspace; } setBoundsElement(workspace.getParentSvg().parentNode as Element | null); - workspace.getFocusableElement().classList.add(SHOWING_DROPDOWNDIV_SELECTOR); return show( field, sourceBlock.RTL, @@ -418,6 +417,9 @@ export function show( aria.State.OWNS, existingOwnership ? [existingOwnership, div.id] : div.id, ); + mainWorkspace + .getFocusableElement() + .classList.add(SHOWING_DROPDOWNDIV_SELECTOR); // When we change `translate` multiple times in close succession, // Chrome may choose to wait and apply them all at once. From d230cae2ce1f4e1c71bc49417e77842366d8e069 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 11 May 2026 12:25:34 -0700 Subject: [PATCH 6/6] test: Add tests for adding/removing showing*Div classes --- packages/blockly/tests/mocha/dropdowndiv_test.js | 10 ++++++++++ packages/blockly/tests/mocha/widget_div_test.js | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/blockly/tests/mocha/dropdowndiv_test.js b/packages/blockly/tests/mocha/dropdowndiv_test.js index 5b9a9e41d0f..62e8c3dc923 100644 --- a/packages/blockly/tests/mocha/dropdowndiv_test.js +++ b/packages/blockly/tests/mocha/dropdowndiv_test.js @@ -207,6 +207,11 @@ suite('DropDownDiv', function () { ), Blockly.DropDownDiv.getContentDiv().parentElement.id, ); + assert.isTrue( + Blockly.getMainWorkspace() + .getFocusableElement() + .classList.contains('blocklyShowingDropDownDiv'), + ); }); }); @@ -416,6 +421,11 @@ suite('DropDownDiv', function () { Blockly.utils.aria.State.OWNS, ), ); + assert.isFalse( + Blockly.getMainWorkspace() + .getFocusableElement() + .classList.contains('blocklyShowingDropDownDiv'), + ); }); }); diff --git a/packages/blockly/tests/mocha/widget_div_test.js b/packages/blockly/tests/mocha/widget_div_test.js index 56c6f9e91b0..ef05a19439e 100644 --- a/packages/blockly/tests/mocha/widget_div_test.js +++ b/packages/blockly/tests/mocha/widget_div_test.js @@ -376,6 +376,11 @@ suite('WidgetDiv', function () { ), Blockly.WidgetDiv.getDiv().id, ); + assert.isTrue( + Blockly.getMainWorkspace() + .getFocusableElement() + .classList.contains('blocklyShowingWidgetDiv'), + ); }); }); @@ -454,6 +459,11 @@ suite('WidgetDiv', function () { Blockly.utils.aria.State.OWNS, ), ); + assert.isFalse( + Blockly.getMainWorkspace() + .getFocusableElement() + .classList.contains('blocklyShowingWidgetDiv'), + ); }); }); });