From 7cde3c8d503220f9386b845e35249025d0bd62bc Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 25 Jun 2026 13:00:39 -0700 Subject: [PATCH 1/3] fix: Improve the keyboard and screenreader accessibility of the backpack --- plugins/workspace-backpack/src/backpack.ts | 159 ++++++++++++++---- .../src/backpack_helpers.ts | 39 ----- 2 files changed, 124 insertions(+), 74 deletions(-) diff --git a/plugins/workspace-backpack/src/backpack.ts b/plugins/workspace-backpack/src/backpack.ts index 9c090cbb3a..e583b9c9b2 100644 --- a/plugins/workspace-backpack/src/backpack.ts +++ b/plugins/workspace-backpack/src/backpack.ts @@ -24,7 +24,13 @@ import {Backpackable, isBackpackable} from './backpackable'; */ export class Backpack extends Blockly.DragTarget - implements Blockly.IAutoHideable, Blockly.IPositionable + implements + Blockly.IComponent, + Blockly.IContextMenu, + Blockly.IAutoHideable, + Blockly.IPositionable, + Blockly.IFocusableNode, + Blockly.IContextMenu { /** The unique id for this component. */ id = 'backpack'; @@ -132,6 +138,7 @@ export class Backpack Blockly.ComponentManager.Capability.AUTOHIDEABLE, Blockly.ComponentManager.Capability.DRAG_TARGET, Blockly.ComponentManager.Capability.POSITIONABLE, + Blockly.ComponentManager.Capability.FOCUSABLE, ], }); this.initFlyout(); @@ -219,9 +226,33 @@ export class Backpack protected createDom() { this.svgGroup_ = Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.G, - {}, + { + id: Blockly.utils.idGenerator.getNextUniqueId(), + tabindex: '0', + class: 'blocklyBackpackContainer', + }, null, ); + Blockly.utils.aria.setState( + this.svgGroup_, + Blockly.utils.aria.State.LABEL, + 'Open backpack', + ); + Blockly.utils.aria.setRole(this.svgGroup_, Blockly.utils.aria.Role.BUTTON); + Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.RECT, + { + width: this.WIDTH_ + 8, + height: this.HEIGHT_ + 8, + class: 'blocklyFocusRing', + x: -4, + y: -4, + rx: 2, + ry: 2, + fill: 'none', + }, + this.svgGroup_, + ); const rnd = Blockly.utils.idGenerator.genUid(); const clip = Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.CLIPPATH, @@ -767,21 +798,24 @@ export class Backpack * Opens the backpack flyout. */ open() { - if (!this.isOpenable()) { + if (!this.isOpenable() || !this.flyout_ || !this.svgGroup_) { return; } + Blockly.utils.aria.setState( + this.svgGroup_, + Blockly.utils.aria.State.LABEL, + 'Close backpack', + ); const jsons = this.contents_.map((text) => JSON.parse(text)); - this.flyout_?.show(jsons); - // TODO: We can remove the setVisible check when updating from ^10.0.0 to - // ^11. - /* eslint-disable @typescript-eslint/no-explicit-any */ - if ( - this.workspace_.scrollbar && - (this.workspace_.scrollbar as any).setVisible - ) { - (this.workspace_.scrollbar as any).setVisible(false); + this.flyout_.show(jsons); + this.workspace_.scrollbar?.setVisible(false); + if (Blockly.keyboardNavigationController.getIsActive()) { + const flyoutWorkspace = this.flyout_.getWorkspace(); + const firstItem = flyoutWorkspace?.getNavigator().getFirstNode(); + if (firstItem) { + Blockly.getFocusManager().focusNode(firstItem); + } } - /* eslint-enable @typescript-eslint/no-explicit-any */ Blockly.Events.fire(new BackpackOpen(true, this.workspace_.id)); } @@ -800,21 +834,18 @@ export class Backpack * Closes the backpack flyout. */ close() { - if (!this.isOpen()) { + if (!this.isOpen() || !this.svgGroup_) { return; } this.flyout_?.hide(); - // TODO: We can remove the setVisible check when updating from ^10.0.0 to - // ^11. - /* eslint-disable @typescript-eslint/no-explicit-any */ - if ( - this.workspace_.scrollbar && - (this.workspace_.scrollbar as any).setVisible - ) { - (this.workspace_.scrollbar as any).setVisible(true); - } - /* eslint-enable @typescript-eslint/no-explicit-any */ + this.workspace_.scrollbar?.setVisible(true); + Blockly.Events.fire(new BackpackOpen(false, this.workspace_.id)); + Blockly.utils.aria.setState( + this.svgGroup_, + Blockly.utils.aria.State.LABEL, + 'Open backpack', + ); } /** @@ -836,11 +867,11 @@ export class Backpack * * @param e Mouse event. */ - protected onClick(e: Event) { + protected onClick(e?: Event) { if (e instanceof MouseEvent && Blockly.browserEvents.isRightButton(e)) { return; } - this.open(); + this.isOpen() ? this.close() : this.open(); const uiEvent = new (Blockly.Events.get(Blockly.Events.CLICK))( null, this.workspace_.id, @@ -923,18 +954,76 @@ export class Backpack * @param e A mouse down event. */ protected blockMouseDownWhenOpenable(e: Event) { - if ( - e instanceof MouseEvent && - !Blockly.browserEvents.isRightButton(e) && - this.isOpenable() - ) { - e.stopPropagation(); // Don't start a workspace scroll. + if (e instanceof MouseEvent) { + if (Blockly.browserEvents.isRightButton(e)) { + this.showContextMenu(e); + e.stopPropagation(); + return; + } + + if (this.isOpenable()) { + e.stopPropagation(); // Don't start a workspace scroll. + } + } + } + + getFocusableElement(): HTMLElement | SVGElement { + if (!this.svgGroup_) { + throw new Error('Attempted to focus an uninitialized backpack'); } + + return this.svgGroup_; + } + + getFocusableTree(): Blockly.IFocusableTree { + return this.workspace_; + } + + onNodeFocus(): void {} + + onNodeBlur(): void {} + + canBeFocused(): boolean { + return true; + } + + performAction(e?: Event): void { + this.onClick(e); + } + + /** + * Show the context menu for the backpack. + * + * @param e Event that triggered the display of the context menu. + */ + showContextMenu(e: Event): void { + if (!this.options.contextMenu?.emptyBackpack) return; + + Blockly.ContextMenu.show( + e, + [ + { + text: Blockly.Msg['EMPTY_BACKPACK'], + enabled: !!this.getCount(), + callback: () => { + this.empty(); + }, + weight: 0, + id: 'empty_backpack', + }, + ], + this.workspace_.RTL, + this.workspace_, + new Blockly.utils.Coordinate( + e instanceof MouseEvent ? e.clientX : this.left_, + e instanceof MouseEvent ? e.clientY : this.top_, + ), + ); } } /** - * Base64 encoded data uri for backpack icon. + * Base64 encoded data uri for backpack icon. */ const backpackSvgDataUri = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC' + @@ -951,7 +1040,7 @@ const backpackSvgDataUri = 'YxNHYtMUg4di0xaDdoMVYxM3oiLz48L2c+PC9nPjwvc3ZnPg=='; /** - * Base64 encoded data uri for backpack icon when filled. + * Base64 encoded data uri for backpack icon when filled. */ const backpackFilledSvgDataUri = 'data:image/svg+xml;base64,PD94bWwgdmVyc2' + @@ -1003,7 +1092,7 @@ Blockly.Css.register(` .blocklyBackpack { opacity: 0.4; } -.blocklyBackpackDarken { +.blocklyBackpackContainer:focus .blocklyBackpack, .blocklyBackpackDarken { opacity: 0.6; } .blocklyBackpack:active { diff --git a/plugins/workspace-backpack/src/backpack_helpers.ts b/plugins/workspace-backpack/src/backpack_helpers.ts index 315d96fc94..8dbf2b22f1 100644 --- a/plugins/workspace-backpack/src/backpack_helpers.ts +++ b/plugins/workspace-backpack/src/backpack_helpers.ts @@ -16,42 +16,6 @@ import * as Blockly from 'blockly/core'; import {Backpack} from './backpack'; import {BackpackContextMenuOptions} from './options'; -/** - * Registers a context menu option to empty the backpack when right-clicked. - * - * @param workspace The workspace to register the context menu option on. - */ -function registerEmptyBackpack(workspace: Blockly.WorkspaceSvg) { - const prevConfigureContextMenu = workspace.configureContextMenu; - workspace.configureContextMenu = (menuOptions, e: Event) => { - const backpack = workspace - .getComponentManager() - .getComponent('backpack') as Backpack; - const backpackClientRect = backpack && backpack.getClientRect(); - if (e instanceof PointerEvent && backpackClientRect) { - if (!backpack || !backpackClientRect.contains(e.clientX, e.clientY)) { - prevConfigureContextMenu && - prevConfigureContextMenu.call(null, menuOptions, e); - return; - } - } - menuOptions.length = 0; - const backpackOptions = { - text: Blockly.Msg['EMPTY_BACKPACK'], - enabled: !!backpack.getCount(), - callback: function () { - backpack.empty(); - }, - scope: { - workspace, - }, - weight: 0, - id: 'empty_backpack', - }; - menuOptions.push(backpackOptions); - }; -} - /** * Registers a context menu option to remove a block from a backpack flyout. */ @@ -240,9 +204,6 @@ export function registerContextMenus( contextMenuOptions: BackpackContextMenuOptions, workspace: Blockly.WorkspaceSvg, ) { - if (contextMenuOptions.emptyBackpack) { - registerEmptyBackpack(workspace); - } if (contextMenuOptions.removeFromBackpack) { registerRemoveFromBackpack(); } From 59830a4e8c90e1c8942a0137b97382f942872576 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 30 Jun 2026 08:51:37 -0700 Subject: [PATCH 2/3] fix: Use localizable strings from core --- plugins/workspace-backpack/README.md | 21 ----------------- plugins/workspace-backpack/src/backpack.ts | 6 ++--- .../src/backpack_helpers.ts | 2 -- plugins/workspace-backpack/src/msg.ts | 23 ------------------- 4 files changed, 3 insertions(+), 49 deletions(-) delete mode 100644 plugins/workspace-backpack/src/msg.ts diff --git a/plugins/workspace-backpack/README.md b/plugins/workspace-backpack/README.md index 6d30cd9cc2..1218feb73a 100644 --- a/plugins/workspace-backpack/README.md +++ b/plugins/workspace-backpack/README.md @@ -120,27 +120,6 @@ beneficial for performance if you expect blocks stacks to be very large. Note: Currently the empty Backpack context menu is registered globally, while the others are registered per workspace. -### Blockly Languages - -We do not currently support translating the text in this plugin to different -languages. However, if you would like to support multiple languages the messages -can be translated by assigning the following properties of Blockly.Msg - -- `EMPTY_BACKPACK` (Default: "Empty") context menu - Empty the backpack. -- `REMOVE_FROM_BACKPACK` (Default: "Remove from Backpack") context menu - Remove - the selected Block from the backpack. -- `COPY_TO_BACKPACK` (Default: "Copy to Backpack") context menu - Copy the - selected Block to the backpack. -- `COPY_ALL_TO_BACKPACK` (Default: "Copy All Blocks to Backpack") Context menu - - copy all Blocks on the workspace to the backpack. -- `PASTE_ALL_FROM_BACKPACK` (Default: "Paste All Blocks from Backpack") context - menu - Paste all Blocks from the backpack to the workspace. - -```javascript -Blockly.Msg['EMPTY_BACKPACK'] = 'Opróżnij plecak'; // Polish -// Inject workspace, etc... -``` - ## API - `init`: Initializes the backpack. diff --git a/plugins/workspace-backpack/src/backpack.ts b/plugins/workspace-backpack/src/backpack.ts index e583b9c9b2..95a16c591f 100644 --- a/plugins/workspace-backpack/src/backpack.ts +++ b/plugins/workspace-backpack/src/backpack.ts @@ -236,7 +236,7 @@ export class Backpack Blockly.utils.aria.setState( this.svgGroup_, Blockly.utils.aria.State.LABEL, - 'Open backpack', + Blockly.Msg['OPEN_BACKPACK'], ); Blockly.utils.aria.setRole(this.svgGroup_, Blockly.utils.aria.Role.BUTTON); Blockly.utils.dom.createSvgElement( @@ -804,7 +804,7 @@ export class Backpack Blockly.utils.aria.setState( this.svgGroup_, Blockly.utils.aria.State.LABEL, - 'Close backpack', + Blockly.Msg['CLOSE_BACKPACK'], ); const jsons = this.contents_.map((text) => JSON.parse(text)); this.flyout_.show(jsons); @@ -844,7 +844,7 @@ export class Backpack Blockly.utils.aria.setState( this.svgGroup_, Blockly.utils.aria.State.LABEL, - 'Open backpack', + Blockly.Msg['OPEN_BACKPACK'], ); } diff --git a/plugins/workspace-backpack/src/backpack_helpers.ts b/plugins/workspace-backpack/src/backpack_helpers.ts index 8dbf2b22f1..84130b03f5 100644 --- a/plugins/workspace-backpack/src/backpack_helpers.ts +++ b/plugins/workspace-backpack/src/backpack_helpers.ts @@ -9,8 +9,6 @@ * @author kozbial@google.com (Monica Kozbial) */ -import './msg'; - import * as Blockly from 'blockly/core'; import {Backpack} from './backpack'; diff --git a/plugins/workspace-backpack/src/msg.ts b/plugins/workspace-backpack/src/msg.ts deleted file mode 100644 index 54c225fefb..0000000000 --- a/plugins/workspace-backpack/src/msg.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Translatable messages used in backpack. - * @author kozbial@google.com (Monica Kozbial) - */ - -import * as Blockly from 'blockly/core'; - -// context menu - Copy all Blocks on the workspace to the backpack. -Blockly.Msg['COPY_ALL_TO_BACKPACK'] = 'Copy All Blocks to Backpack'; -// context menu - Copy the selected Block to the backpack. -Blockly.Msg['COPY_TO_BACKPACK'] = 'Copy to Backpack'; -// context menu - Empty the backpack. -Blockly.Msg['EMPTY_BACKPACK'] = 'Empty'; -// context menu - Paste all Blocks from the backpack to the workspace. -Blockly.Msg['PASTE_ALL_FROM_BACKPACK'] = 'Paste All Blocks from Backpack'; -// context menu - Remove the selected Block from the backpack. -Blockly.Msg['REMOVE_FROM_BACKPACK'] = 'Remove from Backpack'; From 8fb5b3ad4bcd4e060821285461702bb840c77f49 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 30 Jun 2026 08:54:46 -0700 Subject: [PATCH 3/3] refactor: Use named variables instead of ints --- plugins/workspace-backpack/src/backpack.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/plugins/workspace-backpack/src/backpack.ts b/plugins/workspace-backpack/src/backpack.ts index 95a16c591f..153f32a1a3 100644 --- a/plugins/workspace-backpack/src/backpack.ts +++ b/plugins/workspace-backpack/src/backpack.ts @@ -239,16 +239,18 @@ export class Backpack Blockly.Msg['OPEN_BACKPACK'], ); Blockly.utils.aria.setRole(this.svgGroup_, Blockly.utils.aria.Role.BUTTON); + const margin = 8; + const cornerRadius = 2; Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.RECT, { - width: this.WIDTH_ + 8, - height: this.HEIGHT_ + 8, + width: this.WIDTH_ + margin, + height: this.HEIGHT_ + margin, class: 'blocklyFocusRing', - x: -4, - y: -4, - rx: 2, - ry: 2, + x: -1 * (margin / 2), + y: -1 * (margin / 2), + rx: cornerRadius, + ry: cornerRadius, fill: 'none', }, this.svgGroup_,